diff --git a/internal/api/api.go b/internal/api/api.go index b1ee7c658f..15fc7c096f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -12,8 +12,9 @@ import ( "github.com/microsoft/typescript-go/internal/astnav" "github.com/microsoft/typescript-go/internal/checker" "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/project" + "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" @@ -21,18 +22,17 @@ import ( type handleMap[T any] map[Handle[T]]*T -type APIOptions struct { - Logger *project.Logger +type APIInit struct { + Logger logging.Logger + FS vfs.FS + SessionOptions *project.SessionOptions } type API struct { - host APIHost - options APIOptions + logger logging.Logger + session *project.Session - documentStore *project.DocumentStore - configFileRegistry *project.ConfigFileRegistry - - projects handleMap[project.Project] + projects map[Handle[project.Project]]tspath.Path filesMu sync.Mutex files handleMap[ast.SourceFile] symbolsMu sync.Mutex @@ -41,91 +41,22 @@ type API struct { types handleMap[checker.Type] } -var _ project.ProjectHost = (*API)(nil) - -func NewAPI(host APIHost, options APIOptions) *API { +func NewAPI(init *APIInit) *API { api := &API{ - host: host, - options: options, - projects: make(handleMap[project.Project]), + session: project.NewSession(&project.SessionInit{ + Logger: init.Logger, + FS: init.FS, + Options: init.SessionOptions, + }), + projects: make(map[Handle[project.Project]]tspath.Path), files: make(handleMap[ast.SourceFile]), symbols: make(handleMap[ast.Symbol]), types: make(handleMap[checker.Type]), } - api.documentStore = project.NewDocumentStore(project.DocumentStoreOptions{ - ComparePathsOptions: tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), - CurrentDirectory: host.GetCurrentDirectory(), - }, - Hooks: project.DocumentRegistryHooks{ - OnReleaseDocument: func(file *ast.SourceFile) { - _ = api.releaseHandle(string(FileHandle(file))) - }, - }, - }) - - api.configFileRegistry = &project.ConfigFileRegistry{ - Host: api, - } return api } -// DefaultLibraryPath implements ProjectHost. -func (api *API) DefaultLibraryPath() string { - return api.host.DefaultLibraryPath() -} - -// TypingsInstaller implements ProjectHost -func (api *API) TypingsInstaller() *project.TypingsInstaller { - return nil -} - -// DocumentStore implements ProjectHost. -func (api *API) DocumentStore() *project.DocumentStore { - return api.documentStore -} - -// ConfigFileRegistry implements ProjectHost. -func (api *API) ConfigFileRegistry() *project.ConfigFileRegistry { - return api.configFileRegistry -} - -// FS implements ProjectHost. -func (api *API) FS() vfs.FS { - return api.host.FS() -} - -// GetCurrentDirectory implements ProjectHost. -func (api *API) GetCurrentDirectory() string { - return api.host.GetCurrentDirectory() -} - -// Log implements ProjectHost. -func (api *API) Log(s string) { - api.options.Logger.Info(s) -} - -// Log implements ProjectHost. -func (api *API) Trace(s string) { - api.options.Logger.Info(s) -} - -// PositionEncoding implements ProjectHost. -func (api *API) PositionEncoding() lsproto.PositionEncodingKind { - return lsproto.PositionEncodingKindUTF8 -} - -// Client implements ProjectHost. -func (api *API) Client() project.Client { - return nil -} - -// IsWatchEnabled implements ProjectHost. -func (api *API) IsWatchEnabled() bool { - return false -} - func (api *API) HandleRequest(ctx context.Context, method string, payload []byte) ([]byte, error) { params, err := unmarshalPayload(method, payload) if err != nil { @@ -149,7 +80,7 @@ func (api *API) HandleRequest(ctx context.Context, method string, payload []byte case MethodParseConfigFile: return encodeJSON(api.ParseConfigFile(params.(*ParseConfigFileParams).FileName)) case MethodLoadProject: - return encodeJSON(api.LoadProject(params.(*LoadProjectParams).ConfigFileName)) + return encodeJSON(api.LoadProject(ctx, params.(*LoadProjectParams).ConfigFileName)) case MethodGetSymbolAtPosition: params := params.(*GetSymbolAtPositionParams) return encodeJSON(api.GetSymbolAtPosition(ctx, params.Project, params.FileName, int(params.Position))) @@ -180,12 +111,12 @@ func (api *API) HandleRequest(ctx context.Context, method string, payload []byte } func (api *API) Close() { - api.options.Logger.Close() + api.session.Close() } func (api *API) ParseConfigFile(configFileName string) (*ConfigFileResponse, error) { configFileName = api.toAbsoluteFileName(configFileName) - configFileContent, ok := api.host.FS().ReadFile(configFileName) + configFileContent, ok := api.session.FS().ReadFile(configFileName) if !ok { return nil, fmt.Errorf("could not read file %q", configFileName) } @@ -193,7 +124,7 @@ func (api *API) ParseConfigFile(configFileName string) (*ConfigFileResponse, err tsConfigSourceFile := tsoptions.NewTsconfigSourceFileFromFilePath(configFileName, api.toPath(configFileName), configFileContent) parsedCommandLine := tsoptions.ParseJsonSourceFileConfigFileContent( tsConfigSourceFile, - api.host, + api.session, configDir, nil, /*existingOptions*/ configFileName, @@ -207,26 +138,29 @@ func (api *API) ParseConfigFile(configFileName string) (*ConfigFileResponse, err }, nil } -func (api *API) LoadProject(configFileName string) (*ProjectResponse, error) { - configFileName = api.toAbsoluteFileName(configFileName) - configFilePath := api.toPath(configFileName) - p := project.NewConfiguredProject(configFileName, configFilePath, api) - if err := p.LoadConfig(); err != nil { +func (api *API) LoadProject(ctx context.Context, configFileName string) (*ProjectResponse, error) { + project, err := api.session.OpenProject(ctx, api.toAbsoluteFileName(configFileName)) + if err != nil { return nil, err } - p.GetProgram() - data := NewProjectResponse(p) - api.projects[data.Id] = p + data := NewProjectResponse(project) + api.projects[data.Id] = project.ConfigFilePath() return data, nil } func (api *API) GetSymbolAtPosition(ctx context.Context, projectId Handle[project.Project], fileName string, position int) (*SymbolResponse, error) { - project, ok := api.projects[projectId] + projectPath, ok := api.projects[projectId] if !ok { + return nil, errors.New("project ID not found") + } + snapshot, release := api.session.Snapshot() + defer release() + project := snapshot.ProjectCollection.GetProjectByPath(projectPath) + if project == nil { return nil, errors.New("project not found") } - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() + + languageService := ls.NewLanguageService(project, snapshot.Converters()) symbol, err := languageService.GetSymbolAtPosition(ctx, fileName, position) if err != nil || symbol == nil { return nil, err @@ -239,10 +173,17 @@ func (api *API) GetSymbolAtPosition(ctx context.Context, projectId Handle[projec } func (api *API) GetSymbolAtLocation(ctx context.Context, projectId Handle[project.Project], location Handle[ast.Node]) (*SymbolResponse, error) { - project, ok := api.projects[projectId] + projectPath, ok := api.projects[projectId] if !ok { + return nil, errors.New("project ID not found") + } + snapshot, release := api.session.Snapshot() + defer release() + project := snapshot.ProjectCollection.GetProjectByPath(projectPath) + if project == nil { return nil, errors.New("project not found") } + fileHandle, pos, kind, err := parseNodeHandle(location) if err != nil { return nil, err @@ -261,8 +202,7 @@ func (api *API) GetSymbolAtLocation(ctx context.Context, projectId Handle[projec if node == nil { return nil, fmt.Errorf("node of kind %s not found at position %d in file %q", kind.String(), pos, sourceFile.FileName()) } - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() + languageService := ls.NewLanguageService(project, snapshot.Converters()) symbol := languageService.GetSymbolAtLocation(ctx, node) if symbol == nil { return nil, nil @@ -275,18 +215,24 @@ func (api *API) GetSymbolAtLocation(ctx context.Context, projectId Handle[projec } func (api *API) GetTypeOfSymbol(ctx context.Context, projectId Handle[project.Project], symbolHandle Handle[ast.Symbol]) (*TypeResponse, error) { - project, ok := api.projects[projectId] + projectPath, ok := api.projects[projectId] if !ok { + return nil, errors.New("project ID not found") + } + snapshot, release := api.session.Snapshot() + defer release() + project := snapshot.ProjectCollection.GetProjectByPath(projectPath) + if project == nil { return nil, errors.New("project not found") } + api.symbolsMu.Lock() defer api.symbolsMu.Unlock() symbol, ok := api.symbols[symbolHandle] if !ok { return nil, fmt.Errorf("symbol %q not found", symbolHandle) } - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() + languageService := ls.NewLanguageService(project, snapshot.Converters()) t := languageService.GetTypeOfSymbol(ctx, symbol) if t == nil { return nil, nil @@ -295,10 +241,17 @@ func (api *API) GetTypeOfSymbol(ctx context.Context, projectId Handle[project.Pr } func (api *API) GetSourceFile(projectId Handle[project.Project], fileName string) (*ast.SourceFile, error) { - project, ok := api.projects[projectId] + projectPath, ok := api.projects[projectId] if !ok { + return nil, errors.New("project ID not found") + } + snapshot, release := api.session.Snapshot() + defer release() + project := snapshot.ProjectCollection.GetProjectByPath(projectPath) + if project == nil { return nil, errors.New("project not found") } + sourceFile := project.GetProgram().GetSourceFile(fileName) if sourceFile == nil { return nil, fmt.Errorf("source file %q not found", fileName) @@ -313,12 +266,11 @@ func (api *API) releaseHandle(handle string) error { switch handle[0] { case handlePrefixProject: projectId := Handle[project.Project](handle) - project, ok := api.projects[projectId] + _, ok := api.projects[projectId] if !ok { return fmt.Errorf("project %q not found", handle) } delete(api.projects, projectId) - project.Close() case handlePrefixFile: fileId := Handle[ast.SourceFile](handle) api.filesMu.Lock() @@ -353,11 +305,11 @@ func (api *API) releaseHandle(handle string) error { } func (api *API) toAbsoluteFileName(fileName string) string { - return tspath.GetNormalizedAbsolutePath(fileName, api.host.GetCurrentDirectory()) + return tspath.GetNormalizedAbsolutePath(fileName, api.session.GetCurrentDirectory()) } func (api *API) toPath(fileName string) tspath.Path { - return tspath.ToPath(fileName, api.host.GetCurrentDirectory(), api.host.FS().UseCaseSensitiveFileNames()) + return tspath.ToPath(fileName, api.session.GetCurrentDirectory(), api.session.FS().UseCaseSensitiveFileNames()) } func encodeJSON(v any, err error) ([]byte, error) { diff --git a/internal/api/host.go b/internal/api/host.go deleted file mode 100644 index 21136ac5d9..0000000000 --- a/internal/api/host.go +++ /dev/null @@ -1,9 +0,0 @@ -package api - -import "github.com/microsoft/typescript-go/internal/vfs" - -type APIHost interface { - FS() vfs.FS - DefaultLibraryPath() string - GetCurrentDirectory() string -} diff --git a/internal/api/proto.go b/internal/api/proto.go index 58efc5dcd9..e0d5307875 100644 --- a/internal/api/proto.go +++ b/internal/api/proto.go @@ -131,8 +131,8 @@ func NewProjectResponse(project *project.Project) *ProjectResponse { return &ProjectResponse{ Id: ProjectHandle(project), ConfigFileName: project.Name(), - RootFiles: project.GetRootFileNames(), - CompilerOptions: project.GetCompilerOptions(), + RootFiles: project.CommandLine.FileNames(), + CompilerOptions: project.CommandLine.CompilerOptions(), } } diff --git a/internal/api/server.go b/internal/api/server.go index 5f88ad20d3..d5050cb784 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -6,13 +6,16 @@ import ( "encoding/binary" "fmt" "io" + "runtime/debug" "strconv" "sync" "github.com/go-json-experiment/json" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/osvfs" ) @@ -64,10 +67,7 @@ type ServerOptions struct { DefaultLibraryPath string } -var ( - _ APIHost = (*Server)(nil) - _ vfs.FS = (*Server)(nil) -) +var _ vfs.FS = (*Server)(nil) type Server struct { r *bufio.Reader @@ -81,7 +81,7 @@ type Server struct { callbackMu sync.Mutex enabledCallbacks Callback - logger *project.Logger + logger logging.Logger api *API requestId int @@ -100,12 +100,18 @@ func NewServer(options *ServerOptions) *Server { fs: bundled.WrapFS(osvfs.FS()), defaultLibraryPath: options.DefaultLibraryPath, } - logger := project.NewLogger([]io.Writer{options.Err}, "", project.LogLevelVerbose) - api := NewAPI(server, APIOptions{ + logger := logging.NewLogger(options.Err) + server.logger = logger + server.api = NewAPI(&APIInit{ Logger: logger, + FS: server, + SessionOptions: &project.SessionOptions{ + CurrentDirectory: options.Cwd, + DefaultLibraryPath: options.DefaultLibraryPath, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + LoggingEnabled: true, + }, }) - server.logger = logger - server.api = api return server } @@ -133,6 +139,16 @@ func (s *Server) Run() error { switch messageType { case MessageTypeRequest: + defer func() { + if r := recover(); r != nil { + stack := debug.Stack() + err = fmt.Errorf("panic handling request: %v\n%s", r, string(stack)) + if fatalErr := s.sendError(method, err); fatalErr != nil { + panic("fatal error sending panic response") + } + } + }() + result, err := s.handleRequest(method, payload) if err != nil { @@ -265,10 +281,11 @@ func (s *Server) handleConfigure(payload []byte) error { return err } } + // !!! if params.LogFile != "" { - s.logger.SetFile(params.LogFile) + // s.logger.SetFile(params.LogFile) } else { - s.logger.SetFile("") + // s.logger.SetFile("") } return nil } diff --git a/internal/collections/ordered_map.go b/internal/collections/ordered_map.go index 9c321b0941..08802ed01c 100644 --- a/internal/collections/ordered_map.go +++ b/internal/collections/ordered_map.go @@ -80,6 +80,19 @@ func (m *OrderedMap[K, V]) GetOrZero(key K) V { return m.mp[key] } +// EntryAt retrieves the key-value pair at the specified index. +func (m *OrderedMap[K, V]) EntryAt(index int) (K, V, bool) { + if index < 0 || index >= len(m.keys) { + var zero K + var zeroV V + return zero, zeroV, false + } + + key := m.keys[index] + value := m.mp[key] + return key, value, true +} + // Has returns true if the map contains the key. func (m *OrderedMap[K, V]) Has(key K) bool { _, ok := m.mp[key] diff --git a/internal/collections/syncset.go b/internal/collections/syncset.go index 1b9be611c0..3b433de145 100644 --- a/internal/collections/syncset.go +++ b/internal/collections/syncset.go @@ -15,6 +15,14 @@ func (s *SyncSet[T]) Add(key T) { s.m.Store(key, struct{}{}) } +// AddIfAbsent adds the key to the set if it is not already present +// using LoadOrStore. It returns true if the key was not already present +// (opposite of the return value of LoadOrStore). +func (s *SyncSet[T]) AddIfAbsent(key T) bool { + _, loaded := s.m.LoadOrStore(key, struct{}{}) + return !loaded +} + func (s *SyncSet[T]) Delete(key T) { s.m.Delete(key) } diff --git a/internal/compiler/host.go b/internal/compiler/host.go index d4968bd760..dd61fbbf02 100644 --- a/internal/compiler/host.go +++ b/internal/compiler/host.go @@ -1,10 +1,7 @@ package compiler import ( - "sync" - "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/parser" "github.com/microsoft/typescript-go/internal/tsoptions" @@ -25,18 +22,17 @@ type CompilerHost interface { var _ CompilerHost = (*compilerHost)(nil) type compilerHost struct { - currentDirectory string - fs vfs.FS - defaultLibraryPath string - extendedConfigCache *collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry] - extendedConfigCacheOnce sync.Once + currentDirectory string + fs vfs.FS + defaultLibraryPath string + extendedConfigCache tsoptions.ExtendedConfigCache } func NewCachedFSCompilerHost( currentDirectory string, fs vfs.FS, defaultLibraryPath string, - extendedConfigCache *collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry], + extendedConfigCache tsoptions.ExtendedConfigCache, ) CompilerHost { return NewCompilerHost(currentDirectory, cachedvfs.From(fs), defaultLibraryPath, extendedConfigCache) } @@ -45,7 +41,7 @@ func NewCompilerHost( currentDirectory string, fs vfs.FS, defaultLibraryPath string, - extendedConfigCache *collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry], + extendedConfigCache tsoptions.ExtendedConfigCache, ) CompilerHost { return &compilerHost{ currentDirectory: currentDirectory, @@ -80,11 +76,6 @@ func (h *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.Sourc } func (h *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { - h.extendedConfigCacheOnce.Do(func() { - if h.extendedConfigCache == nil { - h.extendedConfigCache = &collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry]{} - } - }) commandLine, _ := tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, h, h.extendedConfigCache) return commandLine } diff --git a/internal/compiler/program.go b/internal/compiler/program.go index 1e159fd49c..1cee20200b 100644 --- a/internal/compiler/program.go +++ b/internal/compiler/program.go @@ -61,6 +61,10 @@ type Program struct { sourceFilesToEmitOnce sync.Once sourceFilesToEmit []*ast.SourceFile + + // Cached unresolved imports for ATA + unresolvedImportsOnce sync.Once + unresolvedImports *collections.Set[string] } // FileExists implements checker.Program. @@ -194,20 +198,23 @@ func NewProgram(opts ProgramOptions) *Program { // Return an updated program for which it is known that only the file with the given path has changed. // In addition to a new program, return a boolean indicating whether the data of the old program was reused. -func (p *Program) UpdateProgram(changedFilePath tspath.Path) (*Program, bool) { +func (p *Program) UpdateProgram(changedFilePath tspath.Path, newHost CompilerHost) (*Program, bool) { oldFile := p.filesByPath[changedFilePath] - newFile := p.Host().GetSourceFile(oldFile.ParseOptions()) + newOpts := p.opts + newOpts.Host = newHost + newFile := newHost.GetSourceFile(oldFile.ParseOptions()) if !canReplaceFileInProgram(oldFile, newFile) { - return NewProgram(p.opts), false + return NewProgram(newOpts), false } // TODO: reverify compiler options when config has changed? result := &Program{ - opts: p.opts, + opts: newOpts, comparePathsOptions: p.comparePathsOptions, processedFiles: p.processedFiles, usesUriStyleNodeCoreModules: p.usesUriStyleNodeCoreModules, programDiagnostics: p.programDiagnostics, hasEmitBlockingDiagnostics: p.hasEmitBlockingDiagnostics, + unresolvedImports: p.unresolvedImports, } result.initCheckerPool() index := core.FindIndex(result.files, func(file *ast.SourceFile) bool { return file.Path() == newFile.Path() }) @@ -255,13 +262,54 @@ func equalCheckJSDirectives(d1 *ast.CheckJsDirective, d2 *ast.CheckJsDirective) return d1 == nil && d2 == nil || d1 != nil && d2 != nil && d1.Enabled == d2.Enabled } -func (p *Program) SourceFiles() []*ast.SourceFile { return p.files } -func (p *Program) Options() *core.CompilerOptions { return p.opts.Config.CompilerOptions() } -func (p *Program) Host() CompilerHost { return p.opts.Host } +func (p *Program) SourceFiles() []*ast.SourceFile { return p.files } +func (p *Program) Options() *core.CompilerOptions { return p.opts.Config.CompilerOptions() } +func (p *Program) CommandLine() *tsoptions.ParsedCommandLine { return p.opts.Config } +func (p *Program) Host() CompilerHost { return p.opts.Host } func (p *Program) GetConfigFileParsingDiagnostics() []*ast.Diagnostic { return slices.Clip(p.opts.Config.GetConfigFileParsingDiagnostics()) } +// GetUnresolvedImports returns the unresolved imports for this program. +// The result is cached and computed only once. +func (p *Program) GetUnresolvedImports() *collections.Set[string] { + p.unresolvedImportsOnce.Do(func() { + if p.unresolvedImports == nil { + p.unresolvedImports = p.extractUnresolvedImports() + } + }) + + return p.unresolvedImports +} + +func (p *Program) extractUnresolvedImports() *collections.Set[string] { + unresolvedSet := &collections.Set[string]{} + + for _, sourceFile := range p.files { + unresolvedImports := p.extractUnresolvedImportsFromSourceFile(sourceFile) + for _, imp := range unresolvedImports { + unresolvedSet.Add(imp) + } + } + + return unresolvedSet +} + +func (p *Program) extractUnresolvedImportsFromSourceFile(file *ast.SourceFile) []string { + var unresolvedImports []string + + resolvedModules := p.resolvedModules[file.Path()] + for cacheKey, resolution := range resolvedModules { + resolved := resolution.IsResolved() + if (!resolved || !tspath.ExtensionIsOneOf(resolution.Extension, tspath.SupportedTSExtensionsWithJsonFlat)) && + !tspath.IsExternalModuleNameRelative(cacheKey.Name) { + unresolvedImports = append(unresolvedImports, cacheKey.Name) + } + } + + return unresolvedImports +} + func (p *Program) SingleThreaded() bool { return p.opts.SingleThreaded.DefaultIfUnknown(p.Options().SingleThreaded).IsTrue() } @@ -1460,6 +1508,10 @@ func (p *Program) GetSourceFileByPath(path tspath.Path) *ast.SourceFile { return p.filesByPath[path] } +func (p *Program) HasSameFileNames(other *Program) bool { + return maps.Equal(p.filesByPath, other.filesByPath) +} + func (p *Program) GetSourceFiles() []*ast.SourceFile { return p.files } diff --git a/internal/core/bfs.go b/internal/core/bfs.go new file mode 100644 index 0000000000..af16606c10 --- /dev/null +++ b/internal/core/bfs.go @@ -0,0 +1,205 @@ +package core + +import ( + "math" + "sync" + "sync/atomic" + + "github.com/microsoft/typescript-go/internal/collections" +) + +type BreadthFirstSearchResult[N comparable] struct { + Stopped bool + Path []N +} + +type breadthFirstSearchJob[N comparable] struct { + node N + parent *breadthFirstSearchJob[N] +} + +type BreadthFirstSearchLevel[N comparable] struct { + jobs *collections.OrderedMap[N, *breadthFirstSearchJob[N]] +} + +func (l *BreadthFirstSearchLevel[N]) Has(node N) bool { + return l.jobs.Has(node) +} + +func (l *BreadthFirstSearchLevel[N]) Delete(node N) { + l.jobs.Delete(node) +} + +func (l *BreadthFirstSearchLevel[N]) Range(f func(node N) bool) { + for node := range l.jobs.Keys() { + if !f(node) { + return + } + } +} + +type BreadthFirstSearchOptions[N comparable] struct { + // Visited is a set of nodes that have already been visited. + // If nil, a new set will be created. + Visited *collections.SyncSet[N] + // PreprocessLevel is a function that, if provided, will be called + // before each level, giving the caller an opportunity to remove nodes. + PreprocessLevel func(*BreadthFirstSearchLevel[N]) +} + +// BreadthFirstSearchParallel performs a breadth-first search on a graph +// starting from the given node. It processes nodes in parallel and returns the path +// from the first node that satisfies the `visit` function back to the start node. +func BreadthFirstSearchParallel[N comparable]( + start N, + neighbors func(N) []N, + visit func(node N) (isResult bool, stop bool), +) BreadthFirstSearchResult[N] { + return BreadthFirstSearchParallelEx(start, neighbors, visit, BreadthFirstSearchOptions[N]{}) +} + +// BreadthFirstSearchParallelEx is an extension of BreadthFirstSearchParallel that allows +// the caller to pass a pre-seeded set of already-visited nodes and a preprocessing function +// that can be used to remove nodes from each level before parallel processing. +func BreadthFirstSearchParallelEx[N comparable]( + start N, + neighbors func(N) []N, + visit func(node N) (isResult bool, stop bool), + options BreadthFirstSearchOptions[N], +) BreadthFirstSearchResult[N] { + visited := options.Visited + if visited == nil { + visited = &collections.SyncSet[N]{} + } + + type result struct { + stop bool + job *breadthFirstSearchJob[N] + next *collections.OrderedMap[N, *breadthFirstSearchJob[N]] + } + + var fallback *breadthFirstSearchJob[N] + // processLevel processes each node at the current level in parallel. + // It produces either a list of jobs to be processed in the next level, + // or a result if the visit function returns true for any node. + processLevel := func(index int, jobs *collections.OrderedMap[N, *breadthFirstSearchJob[N]]) result { + var lowestFallback atomic.Int64 + var lowestGoal atomic.Int64 + var nextJobCount atomic.Int64 + lowestGoal.Store(math.MaxInt64) + lowestFallback.Store(math.MaxInt64) + if options.PreprocessLevel != nil { + options.PreprocessLevel(&BreadthFirstSearchLevel[N]{jobs: jobs}) + } + next := make([][]*breadthFirstSearchJob[N], jobs.Size()) + var wg sync.WaitGroup + i := 0 + for j := range jobs.Values() { + wg.Add(1) + go func(i int, j *breadthFirstSearchJob[N]) { + defer wg.Done() + if int64(i) >= lowestGoal.Load() { + return // Stop processing if we already found a lower result + } + + // If we have already visited this node, skip it. + if !visited.AddIfAbsent(j.node) { + // Note that if we are here, we already visited this node at a + // previous *level*, which means `visit` must have returned false, + // so we don't need to update our result indices. This holds true + // because we deduplicated jobs before queuing the level. + return + } + + isResult, stop := visit(j.node) + if isResult { + // We found a result, so we will stop at this level, but an + // earlier job may still find a true result at a lower index. + if stop { + updateMin(&lowestGoal, int64(i)) + return + } + if fallback == nil { + updateMin(&lowestFallback, int64(i)) + } + } + + if int64(i) >= lowestGoal.Load() { + // If `visit` is expensive, it's likely that by the time we get here, + // a different job has already found a lower index result, so we + // don't even need to collect the next jobs. + return + } + // Add the next level jobs + neighborNodes := neighbors(j.node) + if len(neighborNodes) > 0 { + nextJobCount.Add(int64(len(neighborNodes))) + next[i] = Map(neighborNodes, func(child N) *breadthFirstSearchJob[N] { + return &breadthFirstSearchJob[N]{node: child, parent: j} + }) + } + }(i, j) + i++ + } + wg.Wait() + if index := lowestGoal.Load(); index != math.MaxInt64 { + // If we found a result, return it immediately. + _, job, _ := jobs.EntryAt(int(index)) + return result{stop: true, job: job} + } + if fallback == nil { + if index := lowestFallback.Load(); index != math.MaxInt64 { + _, fallback, _ = jobs.EntryAt(int(index)) + } + } + nextJobs := collections.NewOrderedMapWithSizeHint[N, *breadthFirstSearchJob[N]](int(nextJobCount.Load())) + for _, jobs := range next { + for _, j := range jobs { + if !nextJobs.Has(j.node) { + // Deduplicate synchronously to avoid messy locks and spawning + // unnecessary goroutines. + nextJobs.Set(j.node, j) + } + } + } + return result{next: nextJobs} + } + + createPath := func(job *breadthFirstSearchJob[N]) []N { + var path []N + for job != nil { + path = append(path, job.node) + job = job.parent + } + return path + } + + levelIndex := 0 + level := collections.NewOrderedMapFromList([]collections.MapEntry[N, *breadthFirstSearchJob[N]]{ + {Key: start, Value: &breadthFirstSearchJob[N]{node: start}}, + }) + for level.Size() > 0 { + result := processLevel(levelIndex, level) + if result.stop { + return BreadthFirstSearchResult[N]{Stopped: true, Path: createPath(result.job)} + } else if result.job != nil && fallback == nil { + fallback = result.job + } + level = result.next + levelIndex++ + } + return BreadthFirstSearchResult[N]{Stopped: false, Path: createPath(fallback)} +} + +// updateMin updates the atomic integer `a` to the candidate value if it is less than the current value. +func updateMin(a *atomic.Int64, candidate int64) bool { + for { + current := a.Load() + if current < candidate { + return false + } + if a.CompareAndSwap(current, candidate) { + return true + } + } +} diff --git a/internal/core/bfs_test.go b/internal/core/bfs_test.go new file mode 100644 index 0000000000..e076437c1b --- /dev/null +++ b/internal/core/bfs_test.go @@ -0,0 +1,150 @@ +package core_test + +import ( + "sort" + "sync" + "testing" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "gotest.tools/v3/assert" +) + +func TestBreadthFirstSearchParallel(t *testing.T) { + t.Parallel() + t.Run("basic functionality", func(t *testing.T) { + t.Parallel() + // Test basic functionality with a simple DAG + // Graph: A -> B, A -> C, B -> D, C -> D + graph := map[string][]string{ + "A": {"B", "C"}, + "B": {"D"}, + "C": {"D"}, + "D": {}, + } + + children := func(node string) []string { + return graph[node] + } + + t.Run("find specific node", func(t *testing.T) { + t.Parallel() + result := core.BreadthFirstSearchParallel("A", children, func(node string) (bool, bool) { + return node == "D", true + }) + assert.Equal(t, result.Stopped, true, "Expected search to stop at D") + assert.DeepEqual(t, result.Path, []string{"D", "B", "A"}) + }) + + t.Run("visit all nodes", func(t *testing.T) { + t.Parallel() + var mu sync.Mutex + var visitedNodes []string + result := core.BreadthFirstSearchParallel("A", children, func(node string) (bool, bool) { + mu.Lock() + defer mu.Unlock() + visitedNodes = append(visitedNodes, node) + return false, false // Never stop early + }) + + // Should return nil since we never return true + assert.Equal(t, result.Stopped, false, "Expected search to not stop early") + assert.Assert(t, result.Path == nil, "Expected nil path when visit function never returns true") + + // Should visit all nodes exactly once + sort.Strings(visitedNodes) + expected := []string{"A", "B", "C", "D"} + assert.DeepEqual(t, visitedNodes, expected) + }) + }) + + t.Run("early termination", func(t *testing.T) { + t.Parallel() + // Test that nodes below the target level are not visited + graph := map[string][]string{ + "Root": {"L1A", "L1B"}, + "L1A": {"L2A", "L2B"}, + "L1B": {"L2C"}, + "L2A": {"L3A"}, + "L2B": {}, + "L2C": {}, + "L3A": {}, + } + + children := func(node string) []string { + return graph[node] + } + + var visited collections.SyncSet[string] + core.BreadthFirstSearchParallelEx("Root", children, func(node string) (bool, bool) { + return node == "L2B", true // Stop at level 2 + }, core.BreadthFirstSearchOptions[string]{ + Visited: &visited, + }) + + assert.Assert(t, visited.Has("Root"), "Expected to visit Root") + assert.Assert(t, visited.Has("L1A"), "Expected to visit L1A") + assert.Assert(t, visited.Has("L1B"), "Expected to visit L1B") + assert.Assert(t, visited.Has("L2A"), "Expected to visit L2A") + assert.Assert(t, visited.Has("L2B"), "Expected to visit L2B") + // L2C is non-deterministic + assert.Assert(t, !visited.Has("L3A"), "Expected not to visit L3A") + }) + + t.Run("returns fallback when no other result found", func(t *testing.T) { + t.Parallel() + // Test that fallback behavior works correctly + graph := map[string][]string{ + "A": {"B", "C"}, + "B": {"D"}, + "C": {"D"}, + "D": {}, + } + + children := func(node string) []string { + return graph[node] + } + + var visited collections.SyncSet[string] + result := core.BreadthFirstSearchParallelEx("A", children, func(node string) (bool, bool) { + return node == "A", false // Record A as a fallback, but do not stop + }, core.BreadthFirstSearchOptions[string]{ + Visited: &visited, + }) + + assert.Equal(t, result.Stopped, false, "Expected search to not stop early") + assert.DeepEqual(t, result.Path, []string{"A"}) + assert.Assert(t, visited.Has("B"), "Expected to visit B") + assert.Assert(t, visited.Has("C"), "Expected to visit C") + assert.Assert(t, visited.Has("D"), "Expected to visit D") + }) + + t.Run("returns a stop result over a fallback", func(t *testing.T) { + t.Parallel() + // Test that a stop result is preferred over a fallback + graph := map[string][]string{ + "A": {"B", "C"}, + "B": {"D"}, + "C": {"D"}, + "D": {}, + } + + children := func(node string) []string { + return graph[node] + } + + result := core.BreadthFirstSearchParallel("A", children, func(node string) (bool, bool) { + switch node { + case "A": + return true, false // Record fallback + case "D": + return true, true // Stop at D + default: + return false, false + } + }) + + assert.Equal(t, result.Stopped, true, "Expected search to stop at D") + assert.DeepEqual(t, result.Path, []string{"D", "B", "A"}) + }) +} diff --git a/internal/core/core.go b/internal/core/core.go index a14fb98290..08f56452c4 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -2,6 +2,7 @@ package core import ( "iter" + "maps" "math" "slices" "sort" @@ -587,3 +588,39 @@ func ConcatenateSeq[T any](seqs ...iter.Seq[T]) iter.Seq[T] { } } } + +func comparableValuesEqual[T comparable](a, b T) bool { + return a == b +} + +func DiffMaps[K comparable, V comparable](m1 map[K]V, m2 map[K]V, onAdded func(K, V), onRemoved func(K, V), onChanged func(K, V, V)) { + DiffMapsFunc(m1, m2, comparableValuesEqual, onAdded, onRemoved, onChanged) +} + +func DiffMapsFunc[K comparable, V any](m1 map[K]V, m2 map[K]V, equalValues func(V, V) bool, onAdded func(K, V), onRemoved func(K, V), onChanged func(K, V, V)) { + for k, v1 := range m1 { + if v2, ok := m2[k]; ok { + if !equalValues(v1, v2) { + onChanged(k, v1, v2) + } + } else { + onRemoved(k, v1) + } + } + + for k, v2 := range m2 { + if _, ok := m1[k]; !ok { + onAdded(k, v2) + } + } +} + +// CopyMapInto is maps.Copy, unless dst is nil, in which case it clones and returns src. +// Use CopyMapInto anywhere you would use maps.Copy preceded by a nil check and map initialization. +func CopyMapInto[M1 ~map[K]V, M2 ~map[K]V, K comparable, V any](dst M1, src M2) map[K]V { + if dst == nil { + return maps.Clone(src) + } + maps.Copy(dst, src) + return dst +} diff --git a/internal/core/typeacquisition.go b/internal/core/typeacquisition.go index 5a8b0e16db..6edc0ea22f 100644 --- a/internal/core/typeacquisition.go +++ b/internal/core/typeacquisition.go @@ -1,8 +1,24 @@ package core +import "slices" + type TypeAcquisition struct { Enable Tristate `json:"enable,omitzero"` Include []string `json:"include,omitzero"` Exclude []string `json:"exclude,omitzero"` DisableFilenameBasedTypeAcquisition Tristate `json:"disableFilenameBasedTypeAcquisition,omitzero"` } + +func (ta *TypeAcquisition) Equals(other *TypeAcquisition) bool { + if ta == other { + return true + } + if ta == nil || other == nil { + return false + } + + return (ta.Enable == other.Enable && + slices.Equal(ta.Include, other.Include) && + slices.Equal(ta.Exclude, other.Exclude) && + ta.DisableFilenameBasedTypeAcquisition == other.DisableFilenameBasedTypeAcquisition) +} diff --git a/internal/core/workgroup.go b/internal/core/workgroup.go index 388c332bcd..fcdd133226 100644 --- a/internal/core/workgroup.go +++ b/internal/core/workgroup.go @@ -1,8 +1,11 @@ package core import ( + "context" "sync" "sync/atomic" + + "golang.org/x/sync/errgroup" ) type WorkGroup interface { @@ -86,3 +89,37 @@ func (w *singleThreadedWorkGroup) pop() func() { w.fns = w.fns[:end] return fn } + +// ThrottleGroup is like errgroup.Group but with global concurrency limiting via a semaphore. +type ThrottleGroup struct { + semaphore chan struct{} + group *errgroup.Group +} + +// NewThrottleGroup creates a new ThrottleGroup with the given context and semaphore for concurrency limiting. +func NewThrottleGroup(ctx context.Context, semaphore chan struct{}) *ThrottleGroup { + g, _ := errgroup.WithContext(ctx) + return &ThrottleGroup{ + semaphore: semaphore, + group: g, + } +} + +// Go runs the given function in a new goroutine, but first acquires a slot from the semaphore. +// The semaphore slot is released when the function completes. +func (tg *ThrottleGroup) Go(fn func() error) { + tg.group.Go(func() error { + // Acquire semaphore slot - this will block until a slot is available + tg.semaphore <- struct{}{} + defer func() { + // Release semaphore slot when done + <-tg.semaphore + }() + return fn() + }) +} + +// Wait waits for all goroutines to complete and returns the first error encountered, if any. +func (tg *ThrottleGroup) Wait() error { + return tg.group.Wait() +} diff --git a/internal/execute/extendedconfigcache.go b/internal/execute/extendedconfigcache.go new file mode 100644 index 0000000000..4981026221 --- /dev/null +++ b/internal/execute/extendedconfigcache.go @@ -0,0 +1,37 @@ +package execute + +import ( + "sync" + + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/tspath" +) + +// extendedConfigCache is a minimal implementation of tsoptions.ExtendedConfigCache. +// It is concurrency-safe, but stores cached entries permanently. This implementation +// should not be used for long-running processes where configuration changes over the +// course of multiple compilations. +type extendedConfigCache struct { + mu sync.Mutex + m map[tspath.Path]*tsoptions.ExtendedConfigCacheEntry +} + +var _ tsoptions.ExtendedConfigCache = (*extendedConfigCache)(nil) + +// GetExtendedConfig implements tsoptions.ExtendedConfigCache. +func (e *extendedConfigCache) GetExtendedConfig(fileName string, path tspath.Path, parse func() *tsoptions.ExtendedConfigCacheEntry) *tsoptions.ExtendedConfigCacheEntry { + e.mu.Lock() + if entry, ok := e.m[path]; ok { + e.mu.Unlock() + return entry + } + e.mu.Unlock() + entry := parse() + e.mu.Lock() + if e.m == nil { + e.m = make(map[tspath.Path]*tsoptions.ExtendedConfigCacheEntry) + } + e.m[path] = entry + e.mu.Unlock() + return entry +} diff --git a/internal/execute/tsc.go b/internal/execute/tsc.go index af97431c32..dad9896f0b 100644 --- a/internal/execute/tsc.go +++ b/internal/execute/tsc.go @@ -8,7 +8,6 @@ import ( "time" "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/diagnostics" @@ -164,12 +163,10 @@ func tscCompilation(sys System, commandLine *tsoptions.ParsedCommandLine, testin // !!! convert to options with absolute paths is usually done here, but for ease of implementation, it's done in `tsoptions.ParseCommandLine()` compilerOptionsFromCommandLine := commandLine.CompilerOptions() configForCompilation := commandLine - var extendedConfigCache collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry] + extendedConfigCache := &extendedConfigCache{} var configTime time.Duration if configFileName != "" { - configStart := sys.Now() - configParseResult, errors := tsoptions.GetParsedCommandLineOfConfigFile(configFileName, compilerOptionsFromCommandLine, sys, &extendedConfigCache) - configTime = sys.Now().Sub(configStart) + configParseResult, errors := tsoptions.GetParsedCommandLineOfConfigFile(configFileName, compilerOptionsFromCommandLine, sys, extendedConfigCache) if len(errors) != 0 { // these are unrecoverable errors--exit to report them as diagnostics for _, e := range errors { @@ -195,7 +192,7 @@ func tscCompilation(sys System, commandLine *tsoptions.ParsedCommandLine, testin sys, configForCompilation, reportDiagnostic, - &extendedConfigCache, + extendedConfigCache, configTime, testing, ) @@ -204,7 +201,7 @@ func tscCompilation(sys System, commandLine *tsoptions.ParsedCommandLine, testin sys, configForCompilation, reportDiagnostic, - &extendedConfigCache, + extendedConfigCache, configTime, ) } @@ -227,7 +224,7 @@ func performIncrementalCompilation( sys System, config *tsoptions.ParsedCommandLine, reportDiagnostic diagnosticReporter, - extendedConfigCache *collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry], + extendedConfigCache tsoptions.ExtendedConfigCache, configTime time.Duration, testing bool, ) CommandLineResult { @@ -267,7 +264,7 @@ func performCompilation( sys System, config *tsoptions.ParsedCommandLine, reportDiagnostic diagnosticReporter, - extendedConfigCache *collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry], + extendedConfigCache tsoptions.ExtendedConfigCache, configTime time.Duration, ) CommandLineResult { host := compiler.NewCachedFSCompilerHost(sys.GetCurrentDirectory(), sys.FS(), sys.DefaultLibraryPath(), extendedConfigCache) diff --git a/internal/execute/watcher.go b/internal/execute/watcher.go index 223bdc73fc..4cdd543c92 100644 --- a/internal/execute/watcher.go +++ b/internal/execute/watcher.go @@ -6,12 +6,10 @@ import ( "time" "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/incremental" "github.com/microsoft/typescript-go/internal/tsoptions" - "github.com/microsoft/typescript-go/internal/tspath" ) type Watcher struct { @@ -94,9 +92,9 @@ func (w *Watcher) compileAndEmit() { func (w *Watcher) hasErrorsInTsConfig() bool { // only need to check and reparse tsconfig options/update host if we are watching a config file if w.configFileName != "" { - extendedConfigCache := collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry]{} + extendedConfigCache := &extendedConfigCache{} // !!! need to check that this merges compileroptions correctly. This differs from non-watch, since we allow overriding of previous options - configParseResult, errors := tsoptions.GetParsedCommandLineOfConfigFile(w.configFileName, &core.CompilerOptions{}, w.sys, &extendedConfigCache) + configParseResult, errors := tsoptions.GetParsedCommandLineOfConfigFile(w.configFileName, &core.CompilerOptions{}, w.sys, extendedConfigCache) if len(errors) > 0 { for _, e := range errors { w.reportDiagnostic(e) @@ -109,7 +107,7 @@ func (w *Watcher) hasErrorsInTsConfig() bool { w.configModified = true } w.options = configParseResult - w.host = compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), w.sys.FS(), w.sys.DefaultLibraryPath(), &extendedConfigCache) + w.host = compiler.NewCompilerHost(w.sys.GetCurrentDirectory(), w.sys.FS(), w.sys.DefaultLibraryPath(), extendedConfigCache) } return false } diff --git a/internal/fourslash/fourslash.go b/internal/fourslash/fourslash.go index a8f0f8c997..40da0cead0 100644 --- a/internal/fourslash/fourslash.go +++ b/internal/fourslash/fourslash.go @@ -12,9 +12,7 @@ import ( "github.com/go-json-experiment/json" "github.com/google/go-cmp/cmp" - "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp" @@ -112,26 +110,6 @@ func newLSPPipe() (*lspReader, *lspWriter) { return &lspReader{c: c}, &lspWriter{c: c} } -var sourceFileCache collections.SyncMap[harnessutil.SourceFileCacheKey, *ast.SourceFile] - -type parsedFileCache struct{} - -func (c *parsedFileCache) GetFile(opts ast.SourceFileParseOptions, text string, scriptKind core.ScriptKind) *ast.SourceFile { - key := harnessutil.GetSourceFileCacheKey(opts, text, scriptKind) - cachedFile, ok := sourceFileCache.Load(key) - if !ok { - return nil - } - return cachedFile -} - -func (c *parsedFileCache) CacheFile(opts ast.SourceFileParseOptions, text string, scriptKind core.ScriptKind, sourceFile *ast.SourceFile) { - key := harnessutil.GetSourceFileCacheKey(opts, text, scriptKind) - sourceFileCache.Store(key, sourceFile) -} - -var _ project.ParsedFileCache = (*parsedFileCache)(nil) - const rootDir = "/" func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, content string) *FourslashTest { @@ -169,7 +147,11 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten FS: fs, DefaultLibraryPath: bundled.LibPath(), - ParsedFileCache: &parsedFileCache{}, + ParseCache: &project.ParseCache{ + Options: project.ParseCacheOptions{ + DisableDeletion: true, + }, + }, }) go func() { @@ -202,7 +184,7 @@ func NewFourslash(t *testing.T, capabilities *lsproto.ClientCapabilities, conten // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support // !!! replace with a proper request *after initialize* - f.server.SetCompilerOptionsForInferredProjects(compilerOptions) + f.server.SetCompilerOptionsForInferredProjects(t.Context(), compilerOptions) f.initialize(t, capabilities) for _, file := range testData.Files { f.openFile(t, file.fileName) diff --git a/internal/glob/glob.go b/internal/glob/glob.go new file mode 100644 index 0000000000..f54f691a0d --- /dev/null +++ b/internal/glob/glob.go @@ -0,0 +1,349 @@ +// Copyright 2023 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package glob + +import ( + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// A Glob is an LSP-compliant glob pattern, as defined by the spec: +// https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#documentFilter +// +// NOTE: this implementation is currently only intended for testing. In order +// to make it production ready, we'd need to: +// - verify it against the VS Code implementation +// - add more tests +// - microbenchmark, likely avoiding the element interface +// - resolve the question of what is meant by "character". If it's a UTF-16 +// code (as we suspect) it'll be a bit more work. +// +// Quoting from the spec: +// Glob patterns can have the following syntax: +// - `*` to match one or more characters in a path segment +// - `?` to match on one character in a path segment +// - `**` to match any number of path segments, including none +// - `{}` to group sub patterns into an OR expression. (e.g. `**/*.{ts,js}` +// matches all TypeScript and JavaScript files) +// - `[]` to declare a range of characters to match in a path segment +// (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) +// - `[!...]` to negate a range of characters to match in a path segment +// (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but +// not `example.0`) +// +// Expanding on this: +// - '/' matches one or more literal slashes. +// - any other character matches itself literally. +type Glob struct { + elems []element // pattern elements +} + +// Parse builds a Glob for the given pattern, returning an error if the pattern +// is invalid. +func Parse(pattern string) (*Glob, error) { + g, _, err := parse(pattern, false) + return g, err +} + +func parse(pattern string, nested bool) (*Glob, string, error) { + g := new(Glob) + for len(pattern) > 0 { + switch pattern[0] { + case '/': + pattern = pattern[1:] + g.elems = append(g.elems, slash{}) + + case '*': + if len(pattern) > 1 && pattern[1] == '*' { + if (len(g.elems) > 0 && g.elems[len(g.elems)-1] != slash{}) || (len(pattern) > 2 && pattern[2] != '/') { + return nil, "", errors.New("** may only be adjacent to '/'") + } + pattern = pattern[2:] + g.elems = append(g.elems, starStar{}) + break + } + pattern = pattern[1:] + g.elems = append(g.elems, star{}) + + case '?': + pattern = pattern[1:] + g.elems = append(g.elems, anyChar{}) + + case '{': + var gs group + for pattern[0] != '}' { + pattern = pattern[1:] + groupG, pat, err := parse(pattern, true) + if err != nil { + return nil, "", err + } + if len(pat) == 0 { + return nil, "", errors.New("unmatched '{'") + } + pattern = pat + gs = append(gs, groupG) + } + pattern = pattern[1:] + g.elems = append(g.elems, gs) + + case '}', ',': + if nested { + return g, pattern, nil + } + pattern = g.parseLiteral(pattern, false) + + case '[': + pattern = pattern[1:] + if len(pattern) == 0 { + return nil, "", errBadRange + } + negate := false + if pattern[0] == '!' { + pattern = pattern[1:] + negate = true + } + low, sz, err := readRangeRune(pattern) + if err != nil { + return nil, "", err + } + pattern = pattern[sz:] + if len(pattern) == 0 || pattern[0] != '-' { + return nil, "", errBadRange + } + pattern = pattern[1:] + high, sz, err := readRangeRune(pattern) + if err != nil { + return nil, "", err + } + pattern = pattern[sz:] + if len(pattern) == 0 || pattern[0] != ']' { + return nil, "", errBadRange + } + pattern = pattern[1:] + g.elems = append(g.elems, charRange{negate, low, high}) + + default: + pattern = g.parseLiteral(pattern, nested) + } + } + return g, "", nil +} + +// helper for decoding a rune in range elements, e.g. [a-z] +func readRangeRune(input string) (rune, int, error) { + r, sz := utf8.DecodeRuneInString(input) + var err error + if r == utf8.RuneError { + // See the documentation for DecodeRuneInString. + switch sz { + case 0: + err = errBadRange + case 1: + err = errInvalidUTF8 + } + } + return r, sz, err +} + +var ( + errBadRange = errors.New("'[' patterns must be of the form [x-y]") + errInvalidUTF8 = errors.New("invalid UTF-8 encoding") +) + +func (g *Glob) parseLiteral(pattern string, nested bool) string { + var specialChars string + if nested { + specialChars = "*?{[/}," + } else { + specialChars = "*?{[/" + } + end := strings.IndexAny(pattern, specialChars) + if end == -1 { + end = len(pattern) + } + g.elems = append(g.elems, literal(pattern[:end])) + return pattern[end:] +} + +func (g *Glob) String() string { + var b strings.Builder + for _, e := range g.elems { + fmt.Fprint(&b, e) + } + return b.String() +} + +// element holds a glob pattern element, as defined below. +type element fmt.Stringer + +// element types. +type ( + slash struct{} // One or more '/' separators + literal string // string literal, not containing /, *, ?, {}, or [] + star struct{} // * + anyChar struct{} // ? + starStar struct{} // ** + group []*Glob // {foo, bar, ...} grouping + charRange struct { // [a-z] character range + negate bool + low, high rune + } +) + +func (s slash) String() string { return "/" } +func (l literal) String() string { return string(l) } +func (s star) String() string { return "*" } +func (a anyChar) String() string { return "?" } +func (s starStar) String() string { return "**" } +func (g group) String() string { + var parts []string + for _, g := range g { + parts = append(parts, g.String()) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func (r charRange) String() string { + return "[" + string(r.low) + "-" + string(r.high) + "]" +} + +// Match reports whether the input string matches the glob pattern. +func (g *Glob) Match(input string) bool { + return match(g.elems, input) +} + +func match(elems []element, input string) (ok bool) { + var elem interface{} + for len(elems) > 0 { + elem, elems = elems[0], elems[1:] + switch elem := elem.(type) { + case slash: + if len(input) == 0 || input[0] != '/' { + return false + } + for input[0] == '/' { + input = input[1:] + } + + case starStar: + // Special cases: + // - **/a matches "a" + // - **/ matches everything + // + // Note that if ** is followed by anything, it must be '/' (this is + // enforced by Parse). + if len(elems) > 0 { + elems = elems[1:] + } + + // A trailing ** matches anything. + if len(elems) == 0 { + return true + } + + // Backtracking: advance pattern segments until the remaining pattern + // elements match. + for len(input) != 0 { + if match(elems, input) { + return true + } + _, input = split(input) + } + return false + + case literal: + if !strings.HasPrefix(input, string(elem)) { + return false + } + input = input[len(elem):] + + case star: + var segInput string + segInput, input = split(input) + + elemEnd := len(elems) + for i, e := range elems { + if e == (slash{}) { + elemEnd = i + break + } + } + segElems := elems[:elemEnd] + elems = elems[elemEnd:] + + // A trailing * matches the entire segment. + if len(segElems) == 0 { + break + } + + // Backtracking: advance characters until remaining subpattern elements + // match. + matched := false + for i := range segInput { + if match(segElems, segInput[i:]) { + matched = true + break + } + } + if !matched { + return false + } + + case anyChar: + if len(input) == 0 || input[0] == '/' { + return false + } + input = input[1:] + + case group: + // Append remaining pattern elements to each group member looking for a + // match. + var branch []element + for _, m := range elem { + branch = branch[:0] + branch = append(branch, m.elems...) + branch = append(branch, elems...) + if match(branch, input) { + return true + } + } + return false + + case charRange: + if len(input) == 0 || input[0] == '/' { + return false + } + c, sz := utf8.DecodeRuneInString(input) + if c < elem.low || c > elem.high { + return false + } + input = input[sz:] + + default: + panic(fmt.Sprintf("segment type %T not implemented", elem)) + } + } + + return len(input) == 0 +} + +// split returns the portion before and after the first slash +// (or sequence of consecutive slashes). If there is no slash +// it returns (input, nil). +func split(input string) (first, rest string) { + i := strings.IndexByte(input, '/') + if i < 0 { + return input, "" + } + first = input[:i] + for j := i; j < len(input); j++ { + if input[j] != '/' { + return first, input[j:] + } + } + return first, "" +} diff --git a/internal/ls/completions_test.go b/internal/ls/completions_test.go deleted file mode 100644 index 7bede1bd38..0000000000 --- a/internal/ls/completions_test.go +++ /dev/null @@ -1,1813 +0,0 @@ -package ls_test - -import ( - "context" - "slices" - "testing" - - "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/fourslash" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -var defaultCommitCharacters = []string{".", ",", ";"} - -type testCase struct { - name string - files map[string]string - expectedResult map[string]*testCaseResult - mainFileName string -} - -type testCaseResult struct { - list *lsproto.CompletionList - isIncludes bool - excludes []string -} - -const ( - defaultMainFileName = "/index.ts" - defaultTsconfigFileName = "/tsconfig.json" -) - -func TestCompletions(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. - // Just skip this for now. - t.Skip("bundled files are not embedded") - } - - itemDefaults := &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - } - sortTextLocationPriority := ptrTo(string(ls.SortTextLocationPriority)) - sortTextLocalDeclarationPriority := ptrTo(string(ls.SortTextLocalDeclarationPriority)) - sortTextDeprecatedLocationPriority := ptrTo(string(ls.DeprecateSortText(ls.SortTextLocationPriority))) - sortTextGlobalsOrKeywords := ptrTo(string(ls.SortTextGlobalsOrKeywords)) - fieldKind := ptrTo(lsproto.CompletionItemKindField) - methodKind := ptrTo(lsproto.CompletionItemKindMethod) - functionKind := ptrTo(lsproto.CompletionItemKindFunction) - variableKind := ptrTo(lsproto.CompletionItemKindVariable) - classKind := ptrTo(lsproto.CompletionItemKindClass) - keywordKind := ptrTo(lsproto.CompletionItemKindKeyword) - propertyKind := ptrTo(lsproto.CompletionItemKindProperty) - constantKind := ptrTo(lsproto.CompletionItemKindConstant) - enumMemberKind := ptrTo(lsproto.CompletionItemKindEnumMember) - - stringMembers := []*lsproto.CompletionItem{ - {Label: "charAt", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "charCodeAt", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "concat", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "indexOf", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "lastIndexOf", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "length", Kind: fieldKind, SortText: sortTextLocationPriority}, - {Label: "localeCompare", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "match", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "replace", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "search", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "slice", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "split", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "substring", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toLocaleLowerCase", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toLocaleUpperCase", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toLowerCase", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toString", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toUpperCase", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "trim", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "valueOf", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "substr", Kind: methodKind, SortText: sortTextDeprecatedLocationPriority}, - } - - arrayMembers := []*lsproto.CompletionItem{ - {Label: "concat", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "every", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "filter", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "forEach", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "indexOf", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "join", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "lastIndexOf", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "length", Kind: fieldKind, SortText: sortTextLocationPriority}, - {Label: "map", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "pop", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "push", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "reduce", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "reduceRight", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "reverse", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "shift", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "slice", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "some", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "sort", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "splice", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toLocaleString", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "toString", Kind: methodKind, SortText: sortTextLocationPriority}, - {Label: "unshift", Kind: methodKind, SortText: sortTextLocationPriority}, - } - - testCases := []testCase{ - { - name: "objectLiteralType", - files: map[string]string{ - defaultMainFileName: `export {}; -let x = { foo: 123 }; -x./*a*/`, - }, - expectedResult: map[string]*testCaseResult{ - "a": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "foo", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "basicClassMembers", - files: map[string]string{ - defaultMainFileName: ` -class n { - constructor (public x: number, public y: number, private z: string) { } -} -var t = new n(0, 1, '');t./*a*/`, - }, - expectedResult: map[string]*testCaseResult{ - "a": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "x", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "y", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "cloduleAsBaseClass", - files: map[string]string{ - defaultMainFileName: ` -class A { - constructor(x: number) { } - foo() { } - static bar() { } -} - -module A { - export var x = 1; - export function baz() { } -} - -class D extends A { - constructor() { - super(1); - } - foo2() { } - static bar2() { } -} - -D./*a*/`, - }, - expectedResult: map[string]*testCaseResult{ - "a": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ // !!! `funcionMembersPlus` - { - Label: "bar", - Kind: methodKind, - SortText: sortTextLocalDeclarationPriority, - }, - { - Label: "bar2", - Kind: methodKind, - SortText: sortTextLocalDeclarationPriority, - }, - { - Label: "apply", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "arguments", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "baz", - Kind: functionKind, - SortText: sortTextLocationPriority, - }, - { - Label: "bind", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "call", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "caller", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "length", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "prototype", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "toString", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "x", - Kind: variableKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "lambdaThisMembers", - files: map[string]string{ - defaultMainFileName: `class Foo { - a: number; - b() { - var x = () => { - this./**/; - } - } -}`, - }, - expectedResult: map[string]*testCaseResult{ - "": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "a", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "b", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "memberCompletionInForEach1", - files: map[string]string{ - defaultMainFileName: `var x: string[] = []; -x.forEach(function (y) { y./*1*/`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: stringMembers, - }, - }, - }, - }, - { - name: "completionsTuple", - files: map[string]string{ - defaultMainFileName: `declare const x: [number, number]; -x./**/;`, - }, - expectedResult: map[string]*testCaseResult{ - "": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: append([]*lsproto.CompletionItem{ - { - Label: "0", - Kind: fieldKind, - SortText: sortTextLocationPriority, - InsertText: ptrTo("[0]"), - FilterText: ptrTo(".0"), - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "[0]", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 1}, - End: lsproto.Position{Line: 1, Character: 2}, - }, - }, - }, - }, - { - Label: "1", - Kind: fieldKind, - SortText: sortTextLocationPriority, - InsertText: ptrTo("[1]"), - FilterText: ptrTo(".1"), - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "[1]", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 1}, - End: lsproto.Position{Line: 1, Character: 2}, - }, - }, - }, - }, - }, arrayMembers...), - }, - }, - }, - }, - { - name: "augmentedTypesClass3Fourslash", - files: map[string]string{ - defaultMainFileName: `class c5b { public foo() { } } -namespace c5b { export var y = 2; } // should be ok -/*3*/`, - }, - expectedResult: map[string]*testCaseResult{ - "3": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "c5b", - Kind: classKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - }, - }, - }, - { - name: "objectLiteralBindingInParameter", - files: map[string]string{ - defaultMainFileName: `interface I { x1: number; x2: string } -function f(cb: (ev: I) => any) { } -f(({/*1*/}) => 0); - -[null].reduce(({/*2*/}, b) => b); - -interface Foo { - m(x: { x1: number, x2: number }): void; - prop: I; -} -let x: Foo = { - m({ /*3*/ }) { - }, - get prop(): I { return undefined; }, - set prop({ /*4*/ }) { - } -};`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "x1", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "x2", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "x1", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "x2", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "3": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "x1", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "x2", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "completionListInTypeLiteralInTypeParameter3", - files: map[string]string{ - defaultMainFileName: `interface Foo { - one: string; - two: number; -} - -interface Bar { - foo: T; -} - -var foobar: Bar<{ one: string, /**/`, - }, - expectedResult: map[string]*testCaseResult{ - "": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &[]string{}, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "two", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "completionListInImportClause04", - files: map[string]string{ - defaultMainFileName: `import {/*1*/} from './foo';`, - "/foo.d.ts": `declare class Foo { - static prop1(x: number): number; - static prop1(x: string): string; - static prop2(x: boolean): boolean; -} -export = Foo;`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "prop1", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "prop2", - Kind: methodKind, - SortText: sortTextLocationPriority, - }, - { - Label: "prototype", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "type", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - }, - }, - }, - { - name: "completionListForImportAttributes", - files: map[string]string{ - defaultMainFileName: `declare global { - interface ImportAttributes { - type: "json", - "resolution-mode": "import" - } -} -const str = "hello"; - -import * as t1 from "./a" with { /*1*/ }; -import * as t3 from "./a" with { type: "json", /*3*/ }; -import * as t4 from "./a" with { type: /*4*/ };`, - "/a.ts": `export default {};`, - "/tsconfig.json": `{ "compilerOptions": { "module": "esnext", "target": "esnext" } }`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "resolution-mode", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "type", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "completionsInExport_invalid", - files: map[string]string{ - defaultMainFileName: `function topLevel() {} -if (!!true) { - const blockScoped = 0; - export { /**/ }; -}`, - }, - expectedResult: map[string]*testCaseResult{ - "": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "topLevel", - Kind: functionKind, - SortText: sortTextLocationPriority, - }, - { - Label: "type", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - }, - }, - }, - { - name: "completionListAtIdentifierDefinitionLocations_parameters", - files: map[string]string{ - defaultMainFileName: `var aa = 1; -class bar5{ constructor(public /*constructorParameter1*/`, - }, - expectedResult: map[string]*testCaseResult{ - "constructorParameter1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &[]string{}, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "override", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "private", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "protected", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "public", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "readonly", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - }, - }, - }, - { - name: "completionEntryForClassMembers_StaticWhenBaseTypeIsNotResolved", - files: map[string]string{ - defaultMainFileName: `import React from 'react' -class Slider extends React.Component { - static defau/**/ltProps = { - onMouseDown: () => { }, - onMouseUp: () => { }, - unit: 'px', - } - handleChange = () => 10; -}`, - "/node_modules/@types/react/index.d.ts": `export = React; -export as namespace React; -declare namespace React { - function createElement(): any; - interface Component

{ } - class Component { - static contextType?: any; - context: any; - constructor(props: Readonly

); - setState( - state: ((prevState: Readonly, props: Readonly

) => (Pick | S | null)) | (Pick | S | null), - callback?: () => void - ): void; - } -}`, - }, - expectedResult: map[string]*testCaseResult{ - "": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &[]string{}, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 2, Character: 11}, - End: lsproto.Position{Line: 2, Character: 16}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 2, Character: 11}, - End: lsproto.Position{Line: 2, Character: 23}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "contextType?", - Kind: fieldKind, - SortText: sortTextLocationPriority, - FilterText: ptrTo("contextType"), - InsertText: ptrTo("contextType"), - }, - { - Label: "abstract", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "accessor", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "async", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "constructor", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "declare", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "get", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "override", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "private", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "protected", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "public", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "readonly", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "set", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - { - Label: "static", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - }, - }, - }, - { - name: "completionsInJsxTag", - mainFileName: "/index.tsx", - files: map[string]string{ - "/index.tsx": `declare namespace JSX { - interface Element {} - interface IntrinsicElements { - div: { - /** Doc */ - foo: string - /** Label docs */ - "aria-label": string - } - } -} -class Foo { - render() { -

; -
- } -}`, - "/tsconfig.json": `{ "compilerOptions": { "jsx": "preserve" } }`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "aria-label", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "foo", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "aria-label", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "foo", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "completionsDotDotDotInObjectLiteral1", - files: map[string]string{ - defaultMainFileName: `const foo = { b: 100 }; -const bar: { - a: number; - b: number; -} = { - a: 42, - .../*1*/ -};`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "foo", - Kind: variableKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - excludes: []string{"b"}, - }, - }, - }, - { - name: "extendsKeywordCompletion2", - files: map[string]string{ - defaultMainFileName: `function f1() {} -function f2() {}`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "extends", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - isIncludes: true, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 14}, - End: lsproto.Position{Line: 1, Character: 17}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 14}, - End: lsproto.Position{Line: 1, Character: 17}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "extends", - Kind: keywordKind, - SortText: sortTextGlobalsOrKeywords, - }, - }, - }, - isIncludes: true, - }, - }, - }, - { - name: "paths.ts", - files: map[string]string{ - defaultMainFileName: `import { - CharacterCodes, - compareStringsCaseInsensitive, - compareStringsCaseSensitive, - compareValues, - Comparison, - Debug, - endsWith, - equateStringsCaseInsensitive, - equateStringsCaseSensitive, - GetCanonicalFileName, - getDeclarationFileExtension, - getStringComparer, - identity, - lastOrUndefined, - Path, - some, - startsWith, -} from "./_namespaces/ts.js"; - -/** - * Internally, we represent paths as strings with '/' as the directory separator. - * When we make system calls (eg: LanguageServiceHost.getDirectory()), - * we expect the host to correctly handle paths in our specified format. - * - * @internal - */ -export const directorySeparator = "/"; -/** @internal */ -export const altDirectorySeparator = "\\"; -const urlSchemeSeparator = "://"; -const backslashRegExp = /\\/g; - -b/*a*/ - -//// Path Tests - -/** - * Determines whether a charCode corresponds to '/' or '\'. - * - * @internal - */ -export function isAnyDirectorySeparator(charCode: number): boolean { - return charCode === CharacterCodes.slash || charCode === CharacterCodes.backslash; -}`, - }, - expectedResult: map[string]*testCaseResult{ - "a": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 33, Character: 0}, - End: lsproto.Position{Line: 33, Character: 1}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 33, Character: 0}, - End: lsproto.Position{Line: 33, Character: 1}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "CharacterCodes", - Kind: variableKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - }, - }, - }, - { - name: "jsxTagNameCompletionUnderElementUnclosed", - files: map[string]string{ - "/index.tsx": `declare namespace JSX { - interface IntrinsicElements { - button: any; - div: any; - } -} -function fn() { - return <> - ; -} -function fn2() { - return <> - preceding junk ; -} -function fn3() { - return <> - ; -}`, - }, - mainFileName: "/index.tsx", - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "button", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "button", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - }, - "3": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "button", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - }, - }, - }, - { - name: "tsxCompletionOnClosingTagWithoutJSX1", - files: map[string]string{ - "/index.tsx": `var x1 =
", - Kind: classKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - }, - }, - { - name: "completionListWithLabel", - files: map[string]string{ - defaultMainFileName: `label: while (true) { - break /*1*/ - continue /*2*/ - testlabel: while (true) { - break /*3*/ - continue /*4*/ - break tes/*5*/ - continue tes/*6*/ - } - break /*7*/ - break; /*8*/ -}`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "7": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "3": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "testlabel", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "4": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "testlabel", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "5": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 6, Character: 13}, - End: lsproto.Position{Line: 6, Character: 16}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 6, Character: 13}, - End: lsproto.Position{Line: 6, Character: 16}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "testlabel", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "6": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 7, Character: 16}, - End: lsproto.Position{Line: 7, Character: 19}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 7, Character: 16}, - End: lsproto.Position{Line: 7, Character: 19}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "testlabel", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - { - Label: "label", - Kind: propertyKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "8": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{}, - }, - isIncludes: true, - excludes: []string{"label"}, - }, - }, - }, - { - name: "completionForStringLiteral", - files: map[string]string{ - defaultMainFileName: `type Options = "Option 1" | "Option 2" | "Option 3"; -var x: Options = "/*1*/Option 3";`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "Option 1", - Kind: constantKind, - SortText: sortTextLocationPriority, - - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "Option 1", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 18}, - End: lsproto.Position{Line: 1, Character: 26}, - }, - }, - }, - }, - { - Label: "Option 2", - Kind: constantKind, - SortText: sortTextLocationPriority, - - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "Option 2", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 18}, - End: lsproto.Position{Line: 1, Character: 26}, - }, - }, - }, - }, - { - Label: "Option 3", - Kind: constantKind, - SortText: sortTextLocationPriority, - - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "Option 3", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 1, Character: 18}, - End: lsproto.Position{Line: 1, Character: 26}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - name: "switchCompletions", - files: map[string]string{ - defaultMainFileName: `enum E { A, B } -declare const e: E; -switch (e) { - case E.A: - return 0; - case E./*1*/ -} -declare const f: 1 | 2 | 3; -switch (f) { - case 1: - return 1; - case /*2*/ -} -declare const f2: 'foo' | 'bar' | 'baz'; -switch (f2) { - case 'bar': - return 1; - case '/*3*/' -} -// repro from #52874 -declare let x: "foo" | "bar"; -switch (x) { - case ('/*4*/') -}`, - }, - expectedResult: map[string]*testCaseResult{ - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "B", - Kind: enumMemberKind, - SortText: sortTextLocationPriority, - }, - }, - }, - isIncludes: true, - excludes: []string{"A"}, - }, - "2": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "2", - Kind: constantKind, - SortText: sortTextLocationPriority, - CommitCharacters: &[]string{}, - }, - { - Label: "3", - Kind: constantKind, - SortText: sortTextLocationPriority, - CommitCharacters: &[]string{}, - }, - }, - }, - isIncludes: true, - excludes: []string{"1"}, - }, - "3": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "foo", - Kind: constantKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "foo", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 17, Character: 10}, - End: lsproto.Position{Line: 17, Character: 10}, - }, - }, - }, - }, - { - Label: "baz", - Kind: constantKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "baz", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 17, Character: 10}, - End: lsproto.Position{Line: 17, Character: 10}, - }, - }, - }, - }, - }, - }, - isIncludes: true, - excludes: []string{"bar"}, - }, - "4": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: "foo", - Kind: constantKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "foo", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 22, Character: 11}, - End: lsproto.Position{Line: 22, Character: 11}, - }, - }, - }, - }, - { - Label: "bar", - Kind: constantKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "bar", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 22, Character: 11}, - End: lsproto.Position{Line: 22, Character: 11}, - }, - }, - }, - }, - }, - }, - isIncludes: true, - }, - }, - }, - { - name: "completionForQuotedPropertyInPropertyAssignment1", - files: map[string]string{ - defaultMainFileName: `export interface Configfiles { - jspm: string; - 'jspm:browser': string; -} - -let files: Configfiles; -files = { - /*0*/: '', - '/*1*/': '' -}`, - }, - expectedResult: map[string]*testCaseResult{ - "0": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: itemDefaults, - Items: []*lsproto.CompletionItem{ - { - Label: `"jspm:browser"`, - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - { - Label: "jspm", - Kind: fieldKind, - SortText: sortTextLocationPriority, - }, - }, - }, - }, - "1": { - list: &lsproto.CompletionList{ - IsIncomplete: false, - ItemDefaults: &lsproto.CompletionItemDefaults{ - CommitCharacters: &defaultCommitCharacters, - EditRange: &lsproto.RangeOrEditRangeWithInsertReplace{ - EditRangeWithInsertReplace: &lsproto.EditRangeWithInsertReplace{ - Insert: lsproto.Range{ - Start: lsproto.Position{Line: 8, Character: 4}, - End: lsproto.Position{Line: 8, Character: 4}, - }, - Replace: lsproto.Range{ - Start: lsproto.Position{Line: 8, Character: 4}, - End: lsproto.Position{Line: 8, Character: 4}, - }, - }, - }, - }, - Items: []*lsproto.CompletionItem{ - { - Label: "jspm", - Kind: fieldKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "jspm", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 8, Character: 4}, - End: lsproto.Position{Line: 8, Character: 4}, - }, - }, - }, - }, - { - Label: "jspm:browser", - Kind: fieldKind, - SortText: sortTextLocationPriority, - TextEdit: &lsproto.TextEditOrInsertReplaceEdit{ - TextEdit: &lsproto.TextEdit{ - NewText: "jspm:browser", - Range: lsproto.Range{ - Start: lsproto.Position{Line: 8, Character: 4}, - End: lsproto.Position{Line: 8, Character: 4}, - }, - }, - }, - }, - }, - }, - }, - }, - }, - { - name: "completionListAtInvalidLocation", - files: map[string]string{ - defaultMainFileName: `var v1 = ''; -" /*openString1*/ -var v2 = ''; -"/*openString2*/ -var v3 = ''; -" bar./*openString3*/ -var v4 = ''; -// bar./*inComment1*/ -var v6 = ''; -// /*inComment2*/ -var v7 = ''; -/* /*inComment3*/ -var v11 = ''; - // /*inComment4*/ -var v12 = ''; -type htm/*inTypeAlias*/ - -// /*inComment5*/ -foo; -var v10 = /reg/*inRegExp1*/ex/;`, - }, - expectedResult: map[string]*testCaseResult{ - "openString1": { - list: nil, - }, - "openString2": { - list: nil, - }, - "openString3": { - list: nil, - }, - // !!! isInComment - // "inComment1": { - // list: nil, - // }, - // "inComment2": { - // list: nil, - // }, - // "inComment3": { - // list: nil, - // }, - // "inComment4": { - // list: nil, - // }, - // "inComment5": { - // list: nil, - // }, - // "inTypeAlias": { - // list: nil, - // }, - // "inRegExp1": { - // list: nil, - // }, - }, - }, - { - name: "completionListAtIdentifierDefinitionLocations_destructuring_a", - files: map[string]string{ - defaultMainFileName: `var [x/*variable1*/`, - }, - expectedResult: map[string]*testCaseResult{ - "variable1": { - list: nil, - }, - }, - }, - { - name: "completionListAtIdentifierDefinitionLocations_destructuring_f", - files: map[string]string{ - defaultMainFileName: `var {x, y/*variable6*/`, - }, - expectedResult: map[string]*testCaseResult{ - "variable6": { - list: nil, - }, - }, - }, - { - name: "completionListAfterNumericLiteral_f1", - files: map[string]string{ - defaultMainFileName: `0./*dotOnNumberExpressions1*/`, - }, - expectedResult: map[string]*testCaseResult{ - "dotOnNumberExpressions1": { - list: nil, - }, - }, - }, - { - name: "tsxCompletion9", - files: map[string]string{ - "/file.tsx": `declare module JSX { - interface Element { } - interface IntrinsicElements { - div: { ONE: string; TWO: number; } - } -} -var x1 =
/*1*/ hello /*2*/ world /*3*/
; -var x2 =
/*4*/
/*5*/ world /*6*/
; -var x3 =
/*7*/
/*8*/world/*9*/
; -var x4 =
/*10*/
; -
-/*end*/ -`, - }, - mainFileName: "/file.tsx", - expectedResult: map[string]*testCaseResult{ - "1": { - list: nil, - }, - "2": { - list: nil, - }, - "3": { - list: nil, - }, - "4": { - list: nil, - }, - "5": { - list: nil, - }, - "6": { - list: nil, - }, - "7": { - list: nil, - }, - "8": { - list: nil, - }, - "9": { - list: nil, - }, - "10": { - list: nil, - }, - }, - }, - } - for _, testCase := range testCases { - t.Run(testCase.name, func(t *testing.T) { - t.Parallel() - runTest(t, testCase.files, testCase.expectedResult, testCase.mainFileName) - }) - } -} - -// Ignore completionItem.Data -var ignoreData = cmp.FilterPath( - func(p cmp.Path) bool { - switch p.Last().String() { - case ".Data": - return true - default: - return false - } - }, - cmp.Ignore(), -) - -func runTest(t *testing.T, files map[string]string, expected map[string]*testCaseResult, mainFileName string) { - if mainFileName == "" { - mainFileName = defaultMainFileName - } - parsedFiles := make(map[string]string) - parsedFiles[defaultTsconfigFileName] = `{}` - var markerPositions map[string]*fourslash.Marker - for fileName, content := range files { - if fileName == mainFileName { - testData := fourslash.ParseTestData(t, content, fileName) - markerPositions = testData.MarkerPositions - parsedFiles[fileName] = testData.Files[0].Content // !!! Assumes no usage of @filename, markers only on main file - } else { - parsedFiles[fileName] = content - } - } - ctx := projecttestutil.WithRequestID(t.Context()) - languageService, done := createLanguageService(ctx, mainFileName, parsedFiles) - defer done() - context := &lsproto.CompletionContext{ - TriggerKind: lsproto.CompletionTriggerKindInvoked, - } - ptrTrue := ptrTo(true) - capabilities := &lsproto.CompletionClientCapabilities{ - CompletionItem: &lsproto.ClientCompletionItemOptions{ - SnippetSupport: ptrTrue, - CommitCharactersSupport: ptrTrue, - PreselectSupport: ptrTrue, - LabelDetailsSupport: ptrTrue, - InsertReplaceSupport: ptrTrue, - }, - CompletionList: &lsproto.CompletionListCapabilities{ - ItemDefaults: &[]string{"commitCharacters", "editRange"}, - }, - } - preferences := &ls.UserPreferences{} - - for markerName, expectedResult := range expected { - marker, ok := markerPositions[markerName] - if !ok { - t.Fatalf("No marker found for '%s'", markerName) - } - completionList, err := languageService.ProvideCompletion( - ctx, - ls.FileNameToDocumentURI(mainFileName), - marker.LSPosition, - context, - capabilities, - preferences) - assert.NilError(t, err) - if expectedResult.isIncludes { - assertIncludesItem(t, completionList.List, expectedResult.list) - } else { - assert.DeepEqual(t, completionList.List, expectedResult.list, ignoreData) - } - for _, excludedLabel := range expectedResult.excludes { - for _, item := range completionList.List.Items { - if item.Label == excludedLabel { - t.Fatalf("Label %s should not be included in completion list", excludedLabel) - } - } - } - } -} - -func assertIncludesItem(t *testing.T, actual *lsproto.CompletionList, expected *lsproto.CompletionList) bool { - assert.DeepEqual(t, actual, expected, cmpopts.IgnoreFields(lsproto.CompletionList{}, "Items")) - for _, item := range expected.Items { - index := slices.IndexFunc(actual.Items, func(actualItem *lsproto.CompletionItem) bool { - return actualItem.Label == item.Label - }) - if index == -1 { - t.Fatalf("Label %s not found in actual items. Actual items: %v", item.Label, actual.Items) - } - assert.DeepEqual(t, actual.Items[index], item, ignoreData) - } - return false -} - -func createLanguageService(ctx context.Context, fileName string, files map[string]string) (*ls.LanguageService, func()) { - projectService, _ := projecttestutil.Setup(files, nil) - projectService.OpenFile(fileName, files[fileName], core.GetScriptKindFromFileName(fileName), "") - project := projectService.Projects()[0] - return project.GetLanguageServiceForRequest(ctx) -} - -func ptrTo[T any](v T) *T { - return &v -} diff --git a/internal/ls/converters.go b/internal/ls/converters.go index 18238662f3..f41fa5ab4c 100644 --- a/internal/ls/converters.go +++ b/internal/ls/converters.go @@ -82,49 +82,6 @@ func LanguageKindToScriptKind(languageID lsproto.LanguageKind) core.ScriptKind { } } -func DocumentURIToFileName(uri lsproto.DocumentUri) string { - if strings.HasPrefix(string(uri), "file://") { - parsed := core.Must(url.Parse(string(uri))) - if parsed.Host != "" { - return "//" + parsed.Host + parsed.Path - } - return fixWindowsURIPath(parsed.Path) - } - - // Leave all other URIs escaped so we can round-trip them. - - scheme, path, ok := strings.Cut(string(uri), ":") - if !ok { - panic(fmt.Sprintf("invalid URI: %s", uri)) - } - - authority := "ts-nul-authority" - if rest, ok := strings.CutPrefix(path, "//"); ok { - authority, path, ok = strings.Cut(rest, "/") - if !ok { - panic(fmt.Sprintf("invalid URI: %s", uri)) - } - } - - return "^/" + scheme + "/" + authority + "/" + path -} - -func fixWindowsURIPath(path string) string { - if rest, ok := strings.CutPrefix(path, "/"); ok { - if volume, rest, ok := splitVolumePath(rest); ok { - return volume + rest - } - } - return path -} - -func splitVolumePath(path string) (volume string, rest string, ok bool) { - if len(path) >= 2 && tspath.IsVolumeCharacter(path[0]) && path[1] == ':' { - return strings.ToLower(path[0:2]), path[2:], true - } - return "", path, false -} - // https://github.com/microsoft/vscode-uri/blob/edfdccd976efaf4bb8fdeca87e97c47257721729/src/uri.ts#L455 var extraEscapeReplacer = strings.NewReplacer( ":", "%3A", @@ -166,7 +123,7 @@ func FileNameToDocumentURI(fileName string) lsproto.DocumentUri { return lsproto.DocumentUri(scheme + "://" + authority + "/" + path) } - volume, fileName, _ := splitVolumePath(fileName) + volume, fileName, _ := tspath.SplitVolumePath(fileName) if volume != "" { volume = "/" + extraEscapeReplacer.Replace(volume) } diff --git a/internal/ls/converters_test.go b/internal/ls/converters_test.go index 6225445e6b..25fc94bce2 100644 --- a/internal/ls/converters_test.go +++ b/internal/ls/converters_test.go @@ -41,7 +41,7 @@ func TestDocumentURIToFileName(t *testing.T) { for _, test := range tests { t.Run(string(test.uri), func(t *testing.T) { t.Parallel() - assert.Equal(t, ls.DocumentURIToFileName(test.uri), test.fileName) + assert.Equal(t, test.uri.FileName(), test.fileName) }) } } diff --git a/internal/ls/definition_test.go b/internal/ls/definition_test.go deleted file mode 100644 index 6c424aa301..0000000000 --- a/internal/ls/definition_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package ls_test - -import ( - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/fourslash" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -func TestDefinition(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. - // Just skip this for now. - t.Skip("bundled files are not embedded") - } - - testCases := []struct { - title string - input string - expected map[string]lsproto.DefinitionResponse - }{ - { - title: "localFunction", - input: ` -// @filename: index.ts -function localFunction() { } -/*localFunction*/localFunction();`, - expected: map[string]lsproto.DefinitionResponse{ - "localFunction": { - Locations: &[]lsproto.Location{{ - Uri: ls.FileNameToDocumentURI("/index.ts"), - Range: lsproto.Range{Start: lsproto.Position{Character: 9}, End: lsproto.Position{Character: 22}}, - }}, - }, - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.title, func(t *testing.T) { - t.Parallel() - runDefinitionTest(t, testCase.input, testCase.expected) - }) - } -} - -func runDefinitionTest(t *testing.T, input string, expected map[string]lsproto.DefinitionResponse) { - testData := fourslash.ParseTestData(t, input, "/mainFile.ts") - file := testData.Files[0].FileName() - markerPositions := testData.MarkerPositions - ctx := projecttestutil.WithRequestID(t.Context()) - languageService, done := createLanguageService(ctx, file, map[string]string{ - file: testData.Files[0].Content, - }) - defer done() - - for markerName, expectedResult := range expected { - marker, ok := markerPositions[markerName] - if !ok { - t.Fatalf("No marker found for '%s'", markerName) - } - locations, err := languageService.ProvideDefinition( - ctx, - ls.FileNameToDocumentURI(file), - marker.LSPosition) - assert.NilError(t, err) - assert.DeepEqual(t, locations, expectedResult) - } -} diff --git a/internal/ls/findallreferences_test.go b/internal/ls/findallreferences_test.go deleted file mode 100644 index 9663bdf34b..0000000000 --- a/internal/ls/findallreferences_test.go +++ /dev/null @@ -1,268 +0,0 @@ -package ls_test - -import ( - "strings" - "testing" - - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/fourslash" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -func runFindReferencesTest(t *testing.T, input string, expectedLocations map[string]*collections.Set[string]) { - testData := fourslash.ParseTestData(t, input, "/file1.ts") - markerPositions := testData.MarkerPositions - ctx := projecttestutil.WithRequestID(t.Context()) - service, done := createLanguageService(ctx, testData.Files[0].FileName(), map[string]string{ - testData.Files[0].FileName(): testData.Files[0].Content, - }) - defer done() - - // for each marker location, calculate the expected ref location ahead of time so we don't have to re-calculate each location for every reference call - allExpectedLocations := map[lsproto.Location]string{} - for _, expectedRange := range testData.Ranges { - allExpectedLocations[*service.GetExpectedReferenceFromMarker(expectedRange.FileName(), expectedRange.Position)] = *expectedRange.Name - } - - for requestMarkerName, expectedSet := range expectedLocations { - marker, ok := markerPositions[requestMarkerName] - if !ok { - t.Fatalf("No marker found for '%s'", requestMarkerName) - } - - referencesResp, err := service.TestProvideReferences(marker.FileName(), marker.Position) - assert.NilError(t, err, "Failed to get references for marker '%s'", requestMarkerName) - libReference := 0 - - referencesResult := *referencesResp.Locations - - for _, loc := range referencesResult { - if name, ok := allExpectedLocations[loc]; ok { - // check if returned ref location is in this request's expected set - assert.Assert(t, expectedSet.Has(name), "Reference to '%s' not expected when find all references requested at %s", name, requestMarkerName) - } else if strings.Contains(string(loc.Uri), "//bundled") && strings.Contains(string(loc.Uri), "//libs") { - libReference += 1 - } else { - t.Fatalf("Found reference at loc '%v' when find all references triggered at '%s'", loc, requestMarkerName) - } - } - expectedNum := expectedSet.Len() + libReference - assert.Assert(t, len(referencesResult) == expectedNum, "assertion failed: expected %d references at marker %s, got %d", expectedNum, requestMarkerName, len(referencesResult)) - } -} - -func TestFindReferences(t *testing.T) { - t.Parallel() - - testCases := []struct { - title string - input string - expectedLocations map[string]*collections.Set[string] - }{ - { - title: "getOccurencesIsDefinitionOfParameter", - input: `function f([|/*1*/x|]: number) { - return [|/*2*/x|] + 1 -}`, - expectedLocations: map[string]*collections.Set[string]{ - "1": collections.NewSetFromItems("1", "2"), - "2": collections.NewSetFromItems("1", "2"), - }, - }, - { - title: "findAllRefsUnresolvedSymbols1", - input: `let a: [|/*a0*/Bar|]; -let b: [|/*a1*/Bar|]; -let c: [|/*a2*/Bar|]; -let d: [|/*b0*/Bar|].[|/*c0*/X|]; -let e: [|/*b1*/Bar|].[|/*c1*/X|]; -let f: [|/*b2*/Bar|].[|/*d0*/X|].[|/*e0*/Y|];`, - expectedLocations: map[string]*collections.Set[string]{ - "a0": collections.NewSetFromItems("a0", "a1", "a2"), - "a1": collections.NewSetFromItems("a0", "a1", "a2"), - "a2": collections.NewSetFromItems("a0", "a1", "a2"), - "b0": collections.NewSetFromItems("b0", "b1", "b2"), - "b1": collections.NewSetFromItems("b0", "b1", "b2"), - "b2": collections.NewSetFromItems("b0", "b1", "b2"), - "c0": collections.NewSetFromItems("c0", "c1"), - "c1": collections.NewSetFromItems("c0", "c1"), - "d0": collections.NewSetFromItems("d0"), - "e0": collections.NewSetFromItems("e0"), - }, - }, - { - title: "findAllRefsPrimitive partial-", - input: `const x: [|/*1*/any|] = 0; -const any = 2; -const y: [|/*2*/any|] = any; -function f(b: [|/*3*/boolean|]): [|/*4*/boolean|]; -type T = [|/*5*/never|]; type U = [|/*6*/never|]; -function n(x: [|/*7*/number|]): [|/*8*/number|]; -function o(x: [|/*9*/object|]): [|/*10*/object|]; -function s(x: [|/*11*/string|]): [|/*12*/string|]; -function sy(s: [|/*13*/symbol|]): [|/*14*/symbol|]; -function v(v: [|/*15*/void|]): [|/*16*/void|]; -`, - expectedLocations: map[string]*collections.Set[string]{ - "1": collections.NewSetFromItems("1", "2"), - "2": collections.NewSetFromItems("1", "2"), - "3": collections.NewSetFromItems("3", "4"), - "4": collections.NewSetFromItems("3", "4"), - "5": collections.NewSetFromItems("5", "6"), - "6": collections.NewSetFromItems("5", "6"), - "7": collections.NewSetFromItems("7", "8"), - "8": collections.NewSetFromItems("7", "8"), - "9": collections.NewSetFromItems("9", "10"), - "10": collections.NewSetFromItems("9", "10"), - "11": collections.NewSetFromItems("11", "12"), - "12": collections.NewSetFromItems("11", "12"), - "13": collections.NewSetFromItems("13", "14"), - "14": collections.NewSetFromItems("13", "14"), - "15": collections.NewSetFromItems("15", "16"), - "16": collections.NewSetFromItems("15", "16"), - }, - }, - { - title: "findAllReferencesDynamicImport1 Partial", - input: `export function foo() { return "foo"; } -[|/*1*/import([|"/*2*/./foo"|])|] -[|/*3*/var x = import([|"/*4*/./foo"|])|]`, - expectedLocations: map[string]*collections.Set[string]{ - "1": {}, - }, - }, - { - title: "findAllRefsForDefaultExport02 partial", - input: `[|/*1*/export default function [|/*2*/DefaultExportedFunction|]() { - return [|/*3*/DefaultExportedFunction|]; -}|] - -var x: typeof [|/*4*/DefaultExportedFunction|]; - -var y = [|/*5*/DefaultExportedFunction|](); - -[|/*6*/namespace [|/*7*/DefaultExportedFunction|] { -}|]`, - expectedLocations: map[string]*collections.Set[string]{ - "2": collections.NewSetFromItems("2", "3", "4", "5"), - "3": collections.NewSetFromItems("2", "3", "4", "5"), - "4": collections.NewSetFromItems("2", "3", "4", "5"), - "5": collections.NewSetFromItems("2", "3", "4", "5"), - "7": collections.NewSetFromItems("7"), - }, - }, - { - title: "findAllReferPropertyAccessExpressionHeritageClause", - input: `class B {} -function foo() { - return {[|/*1*/B|]: B}; -} -class C extends (foo()).[|/*2*/B|] {} -class C1 extends foo().[|/*3*/B|] {}`, - expectedLocations: map[string]*collections.Set[string]{ - "1": collections.NewSetFromItems("1", "2", "3"), - "2": collections.NewSetFromItems("1", "2", "3"), - "3": collections.NewSetFromItems("1", "2", "3"), - }, - }, - { - title: "findAllRefsForFunctionExpression01 partial-", - input: `var foo = [|/*1*/function [|/*2*/foo|](a = [|/*3*/foo|](), b = () => [|/*4*/foo|]) { - [|/*5*/foo|]([|/*6*/foo|], [|/*7*/foo|]); -}|]`, - expectedLocations: map[string]*collections.Set[string]{ - "2": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - "3": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - "4": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - "5": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - "6": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - "7": collections.NewSetFromItems("2", "3", "4", "5", "6", "7"), - }, - }, - { - title: "findAllRefsForObjectSpread-", - input: `interface A1 { readonly [|/*0*/a|]: string }; -interface A2 { [|/*1*/a|]?: number }; -let a1: A1; -let a2: A2; -let a12 = { ...a1, ...a2 }; -a12.[|/*2*/a|]; -a1.[|/*3*/a|];`, - expectedLocations: map[string]*collections.Set[string]{ - "0": collections.NewSetFromItems("0", "2", "3"), - "1": collections.NewSetFromItems("1", "2"), - "2": collections.NewSetFromItems("0", "1", "2", "3"), - "3": collections.NewSetFromItems("0", "2", "3"), - }, - }, - { - title: "findAllRefsForObjectLiteralProperties-", - input: `var x = { - [|/*1*/property|]: {} -}; - -x.[|/*2*/property|]; - -[|/*3*/let {[|/*4*/property|]: pVar} = x;|]`, - expectedLocations: map[string]*collections.Set[string]{ - "1": collections.NewSetFromItems("1", "2", "4"), - "2": collections.NewSetFromItems("1", "2", "4"), - "4": collections.NewSetFromItems("1", "2", "4"), - }, - }, - { - title: "findAllRefsImportEquals-", - input: `import j = N.[|/*0*/q|]; -namespace N { export const [|/*1*/q|] = 0; }`, - expectedLocations: map[string]*collections.Set[string]{ - // "0": collections.NewSetFromItems("0", "1"), - }, - }, - { - title: "findAllRefsForRest", - input: `interface Gen { -x: number -[|/*0*/parent|]: Gen; -millennial: string; -} -let t: Gen; -var { x, ...rest } = t; -rest.[|/*1*/parent|];`, - expectedLocations: map[string]*collections.Set[string]{ - "0": collections.NewSetFromItems("0", "1"), - "1": collections.NewSetFromItems("0", "1"), - }, - }, - { - title: "findAllRefsForVariableInExtendsClause01 -", - input: `[|/*1*/var [|/*2*/Base|] = class { };|] -class C extends [|/*3*/Base|] { }`, - expectedLocations: map[string]*collections.Set[string]{ - "2": collections.NewSetFromItems("2", "3"), - "3": collections.NewSetFromItems("2", "3"), - }, - }, - { - title: "findAllRefsTrivia", - input: `export interface A { - /** Comment */ - [|/*m1*/method|](): string; - /** Comment */ - [|/*m2*/method|](format: string): string; -}`, - expectedLocations: map[string]*collections.Set[string]{ - "m1": collections.NewSetFromItems("m1", "m2"), - "m2": collections.NewSetFromItems("m1", "m2"), - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.title, func(t *testing.T) { - t.Parallel() - runFindReferencesTest(t, testCase.input, testCase.expectedLocations) - }) - } -} diff --git a/internal/ls/findallreferencesexport_test.go b/internal/ls/findallreferencesexport_test.go deleted file mode 100644 index e1ead1e244..0000000000 --- a/internal/ls/findallreferencesexport_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package ls - -import ( - "context" - - "github.com/microsoft/typescript-go/internal/astnav" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" -) - -func (l *LanguageService) GetExpectedReferenceFromMarker(fileName string, pos int) *lsproto.Location { - // Temporary testing function--this function only works for markers that are on symbols/names. - // We won't need this once marker ranges are implemented, or once reference tests are baselined - _, sourceFile := l.tryGetProgramAndFile(fileName) - node := astnav.GetTouchingPropertyName(sourceFile, pos) - return &lsproto.Location{ - Uri: FileNameToDocumentURI(fileName), - Range: *l.getRangeOfNode(node, sourceFile, nil /*endNode*/), - } -} - -func (l *LanguageService) TestProvideReferences(fileName string, pos int) (lsproto.ReferencesResponse, error) { - _, sourceFile := l.tryGetProgramAndFile(fileName) - lsPos := l.converters.PositionToLineAndCharacter(sourceFile, core.TextPos(pos)) - return l.ProvideReferences(context.TODO(), &lsproto.ReferenceParams{ - TextDocument: lsproto.TextDocumentIdentifier{ - Uri: FileNameToDocumentURI(fileName), - }, - Position: lsPos, - }) -} diff --git a/internal/ls/host.go b/internal/ls/host.go index 493596fc45..a2d4888e43 100644 --- a/internal/ls/host.go +++ b/internal/ls/host.go @@ -2,11 +2,8 @@ package ls import ( "github.com/microsoft/typescript-go/internal/compiler" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" ) type Host interface { GetProgram() *compiler.Program - GetPositionEncoding() lsproto.PositionEncodingKind - GetLineMap(fileName string) *LineMap } diff --git a/internal/ls/hover_test.go b/internal/ls/hover_test.go deleted file mode 100644 index ec4efc9324..0000000000 --- a/internal/ls/hover_test.go +++ /dev/null @@ -1,184 +0,0 @@ -package ls_test - -import ( - "context" - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/fourslash" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -func TestHover(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. - // Just skip this for now. - t.Skip("bundled files are not embedded") - } - - testCases := []struct { - title string - input string - expected map[string]*lsproto.Hover - }{ - { - title: "JSDocLinksPanic", - input: ` -// @filename: index.ts -/** - * A function with JSDoc links that previously caused panic - * {@link console.log} and {@linkcode Array.from} and {@linkplain Object.keys} - */ -function myFunction() { - return "test"; -} - -/*marker*/myFunction();`, - expected: map[string]*lsproto.Hover{ - "marker": { - Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ - MarkupContent: &lsproto.MarkupContent{ - Kind: lsproto.MarkupKindMarkdown, - Value: "```tsx\nfunction myFunction(): string\n```\nA function with JSDoc links that previously caused panic\n`console.log` and `Array.from` and `Object.keys`", - }, - }, - }, - }, - }, - { - title: "JSDocParamHoverFunctionDeclaration", - input: ` -// @filename: index.js -/** - * @param {string} param - the greatest of days - */ -function /*marker*/myFunction(param) { - return "test" + param; -} - -myFunction();`, - expected: map[string]*lsproto.Hover{ - "marker": { - Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ - MarkupContent: &lsproto.MarkupContent{ - Kind: lsproto.MarkupKindMarkdown, - Value: "```tsx\nfunction myFunction(param: string): string\n```\n\n\n*@param* `param` - the greatest of days\n", - }, - }, - }, - }, - }, - { - title: "JSDocParamHoverFunctionCall", - input: ` -// @filename: index.js -/** - * @param {string} param - the greatest of days - */ -function myFunction(param) { - return "test" + param; -} - -/*marker*/myFunction();`, - expected: map[string]*lsproto.Hover{ - "marker": { - Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ - MarkupContent: &lsproto.MarkupContent{ - Kind: lsproto.MarkupKindMarkdown, - Value: "```tsx\nfunction myFunction(param: string): string\n```\n\n\n*@param* `param` - the greatest of days\n", - }, - }, - }, - }, - }, - { - title: "JSDocParamHoverParameter", - input: ` -// @filename: index.js -/** - * @param {string} param - the greatest of days - */ -function myFunction(/*marker*/param) { - return "test" + param; -} - -myFunction();`, - expected: map[string]*lsproto.Hover{ - "marker": { - Contents: lsproto.MarkupContentOrStringOrMarkedStringWithLanguageOrMarkedStrings{ - MarkupContent: &lsproto.MarkupContent{ - Kind: lsproto.MarkupKindMarkdown, - Value: "```tsx\n(parameter) param: string\n```\n- the greatest of days\n", - }, - }, - }, - }, - }, - { - title: "JSDocParamHoverTagIdentifier", - input: ` -// @filename: index.js -/** - * @param {string} /*marker*/param - the greatest of days - */ -function myFunction(param) { - return "test" + param; -} - -myFunction();`, - expected: map[string]*lsproto.Hover{ - // TODO: Should have same result as hovering on the parameter itself. - "marker": nil, - }, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.title, func(t *testing.T) { - t.Parallel() - runHoverTest(t, testCase.input, testCase.expected) - }) - } -} - -func runHoverTest(t *testing.T, input string, expected map[string]*lsproto.Hover) { - testData := fourslash.ParseTestData(t, input, "/mainFile.ts") - file := testData.Files[0].FileName() - markerPositions := testData.MarkerPositions - ctx := projecttestutil.WithRequestID(t.Context()) - languageService, done := createLanguageServiceForHover(ctx, file, map[string]any{ - file: testData.Files[0].Content, - }) - defer done() - - for markerName, expectedResult := range expected { - marker, ok := markerPositions[markerName] - if !ok { - t.Fatalf("No marker found for '%s'", markerName) - } - result, err := languageService.ProvideHover( - ctx, - ls.FileNameToDocumentURI(file), - marker.LSPosition) - assert.NilError(t, err) - hovers := result.Hover - if expectedResult == nil { - assert.Assert(t, hovers == nil) - } else { - assert.Assert(t, hovers != nil) - assert.DeepEqual(t, hovers, expectedResult) - } - } -} - -func createLanguageServiceForHover(ctx context.Context, fileName string, files map[string]any) (*ls.LanguageService, func()) { - projectService, _ := projecttestutil.Setup(files, nil) - projectService.OpenFile(fileName, files[fileName].(string), core.GetScriptKindFromFileName(fileName), "") - project := projectService.Projects()[0] - return project.GetLanguageServiceForRequest(ctx) -} diff --git a/internal/ls/languageservice.go b/internal/ls/languageservice.go index 5d604eee3c..7e47e50e34 100644 --- a/internal/ls/languageservice.go +++ b/internal/ls/languageservice.go @@ -11,14 +11,13 @@ type LanguageService struct { converters *Converters } -func NewLanguageService(host Host) *LanguageService { +func NewLanguageService(host Host, converters *Converters) *LanguageService { return &LanguageService{ host: host, - converters: NewConverters(host.GetPositionEncoding(), host.GetLineMap), + converters: converters, } } -// GetProgram updates the program if the project version has changed. func (l *LanguageService) GetProgram() *compiler.Program { return l.host.GetProgram() } @@ -30,7 +29,7 @@ func (l *LanguageService) tryGetProgramAndFile(fileName string) (*compiler.Progr } func (l *LanguageService) getProgramAndFile(documentURI lsproto.DocumentUri) (*compiler.Program, *ast.SourceFile) { - fileName := DocumentURIToFileName(documentURI) + fileName := documentURI.FileName() program, file := l.tryGetProgramAndFile(fileName) if file == nil { panic("file not found: " + fileName) diff --git a/internal/ls/signaturehelp_test.go b/internal/ls/signaturehelp_test.go deleted file mode 100644 index ab639c4d41..0000000000 --- a/internal/ls/signaturehelp_test.go +++ /dev/null @@ -1,1077 +0,0 @@ -package ls_test - -import ( - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/fourslash" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -type verifySignatureHelpOptions struct { - docComment string - text string - parameterSpan string - parameterCount int - activeParameter *lsproto.UintegerOrNull - // triggerReason ls.SignatureHelpTriggerReason - // tags?: ReadonlyArray; -} - -func TestSignatureHelp(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - // Without embedding, we'd need to read all of the lib files out from disk into the MapFS. - // Just skip this for now. - t.Skip("bundled files are not embedded") - } - - testCases := []struct { - title string - input string - expected map[string]verifySignatureHelpOptions - noContext bool - }{ - { - title: "SignatureHelpCallExpressions", - input: `function fnTest(str: string, num: number) { } -fnTest(/*1*/'', /*2*/5);`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: `fnTest(str: string, num: number): void`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "str: string", - }, - "2": { - text: `fnTest(str: string, num: number): void`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "num: number", - }, - }, - }, - { - title: "SignatureHelp_contextual", - input: `interface I { - m(n: number, s: string): void; - m2: () => void; -} -declare function takesObj(i: I): void; -takesObj({ m: (/*takesObj0*/) }); -takesObj({ m(/*takesObj1*/) }); -takesObj({ m: function(/*takesObj2*/) }); -takesObj({ m2: (/*takesObj3*/) }) -declare function takesCb(cb: (n: number, s: string, b: boolean) => void): void; -takesCb((/*contextualParameter1*/)); -takesCb((/*contextualParameter1b*/) => {}); -takesCb((n, /*contextualParameter2*/)); -takesCb((n, s, /*contextualParameter3*/)); -takesCb((n,/*contextualParameter3_2*/ s, b)); -takesCb((n, s, b, /*contextualParameter4*/)) -type Cb = () => void; -const cb: Cb = (/*contextualTypeAlias*/ -const cb2: () => void = (/*contextualFunctionType*/)`, - expected: map[string]verifySignatureHelpOptions{ - "takesObj0": { - text: "m(n: number, s: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - "takesObj1": { - text: "m(n: number, s: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - "takesObj2": { - text: "m(n: number, s: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - "takesObj3": { - text: "m2(): void", - parameterCount: 0, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "", - }, - "contextualParameter1": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - "contextualParameter1b": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - "contextualParameter2": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "s: string", - }, - "contextualParameter3": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}, - parameterSpan: "b: boolean", - }, - "contextualParameter3_2": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "s: string", - }, - "contextualParameter4": { - text: "cb(n: number, s: string, b: boolean): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(3))}, - parameterSpan: "", - }, - "contextualTypeAlias": { - text: "Cb(): void", - parameterCount: 0, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "", - }, - "contextualFunctionType": { - text: "cb2(): void", - parameterCount: 0, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "", - }, - }, - }, - { - title: "signatureHelpAnonymousFunction", - input: `var anonymousFunctionTest = function(n: number, s: string): (a: number, b: string) => string { - return null; -} -anonymousFunctionTest(5, "")(/*anonymousFunction1*/1, /*anonymousFunction2*/"");`, - expected: map[string]verifySignatureHelpOptions{ - "anonymousFunction1": { - text: `(a: number, b: string): string`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "a: number", - }, - "anonymousFunction2": { - text: `(a: number, b: string): string`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "b: string", - }, - }, - }, - { - title: "signatureHelpAtEOFs", - input: `function Foo(arg1: string, arg2: string) { -} - -Foo(/**/`, - expected: map[string]verifySignatureHelpOptions{ - "": { - text: "Foo(arg1: string, arg2: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "arg1: string", - }, - }, - }, - { - title: "signatureHelpBeforeSemicolon1", - input: `function Foo(arg1: string, arg2: string) { -} - -Foo(/**/;`, - expected: map[string]verifySignatureHelpOptions{ - "": { - text: "Foo(arg1: string, arg2: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "arg1: string", - }, - }, - }, - { - title: "signatureHelpCallExpression", - input: `function fnTest(str: string, num: number) { } -fnTest(/*1*/'', /*2*/5);`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: `fnTest(str: string, num: number): void`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "str: string", - }, - "2": { - text: `fnTest(str: string, num: number): void`, - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "num: number", - }, - }, - }, - { - title: "signatureHelpConstructExpression", - input: `class sampleCls { constructor(str: string, num: number) { } } -var x = new sampleCls(/*1*/"", /*2*/5);`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "sampleCls(str: string, num: number): sampleCls", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "str: string", - }, - "2": { - text: "sampleCls(str: string, num: number): sampleCls", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "num: number", - }, - }, - }, - { - title: "signatureHelpConstructorInheritance", - input: `class base { -constructor(s: string); -constructor(n: number); -constructor(a: any) { } -} -class B1 extends base { } -class B2 extends B1 { } -class B3 extends B2 { - constructor() { - super(/*indirectSuperCall*/3); - } -}`, - expected: map[string]verifySignatureHelpOptions{ - "indirectSuperCall": { - text: "B2(n: number): B2", - parameterCount: 1, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "n: number", - }, - }, - }, - { - title: "signatureHelpConstructorOverload", - input: `class clsOverload { constructor(); constructor(test: string); constructor(test?: string) { } } -var x = new clsOverload(/*1*/); -var y = new clsOverload(/*2*/'');`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "clsOverload(): clsOverload", - parameterCount: 0, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - }, - "2": { - text: "clsOverload(test: string): clsOverload", - parameterCount: 1, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "test: string", - }, - }, - }, - { - title: "signatureHelpEmptyLists", - input: `function Foo(arg1: string, arg2: string) { - } - - Foo(/*1*/); - function Bar(arg1: string, arg2: string) { } - Bar();`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "Foo(arg1: string, arg2: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "arg1: string", - }, - "2": { - text: "Bar(arg1: string, arg2: string): void", - parameterCount: 1, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "T", - }, - }, - }, - { - title: "signatureHelpExpandedRestTuples", - input: `export function complex(item: string, another: string, ...rest: [] | [settings: object, errorHandler: (err: Error) => void] | [errorHandler: (err: Error) => void, ...mixins: object[]]) { - -} - -complex(/*1*/); -complex("ok", "ok", /*2*/); -complex("ok", "ok", e => void e, {}, /*3*/);`, - - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "complex(item: string, another: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "item: string", - }, - "2": { - text: "complex(item: string, another: string, settings: object, errorHandler: (err: Error) => void): void", - parameterCount: 4, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}, - parameterSpan: "settings: object", - }, - "3": { - text: "complex(item: string, another: string, errorHandler: (err: Error) => void, ...mixins: object[]): void", - parameterCount: 4, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(3))}, - parameterSpan: "...mixins: object[]", - }, - }, - }, - { - title: "signatureHelpExpandedRestUnlabeledTuples", - input: `export function complex(item: string, another: string, ...rest: [] | [object, (err: Error) => void] | [(err: Error) => void, ...object[]]) { - -} - -complex(/*1*/); -complex("ok", "ok", /*2*/); -complex("ok", "ok", e => void e, {}, /*3*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "complex(item: string, another: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "item: string", - }, - "2": { - text: "complex(item: string, another: string, rest_0: object, rest_1: (err: Error) => void): void", - parameterCount: 4, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}, - parameterSpan: "rest_0: object", - }, - "3": { - text: "complex(item: string, another: string, rest_0: (err: Error) => void, ...rest: object[]): void", - parameterCount: 4, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(3))}, - parameterSpan: "...rest: object[]", - }, - }, - }, - { - title: "signatureHelpExpandedTuplesArgumentIndex", - input: `function foo(...args: [string, string] | [number, string, string] -) { - -} -foo(123/*1*/,) -foo(""/*2*/, ""/*3*/) -foo(123/*4*/, ""/*5*/, ) -foo(123/*6*/, ""/*7*/, ""/*8*/)`, - expected: map[string]verifySignatureHelpOptions{ - "1": { - text: "foo(args_0: string, args_1: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "args_0: string", - }, - "2": { - text: "foo(args_0: string, args_1: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "args_0: string", - }, - "3": { - text: "foo(args_0: string, args_1: string): void", - parameterCount: 2, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "args_1: string", - }, - "4": { - text: "foo(args_0: number, args_1: string, args_2: string): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "args_0: number", - }, - "5": { - text: "foo(args_0: number, args_1: string, args_2: string): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "args_1: string", - }, - "6": { - text: "foo(args_0: number, args_1: string, args_2: string): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, - parameterSpan: "args_0: number", - }, - "7": { - text: "foo(args_0: number, args_1: string, args_2: string): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}, - parameterSpan: "args_1: string", - }, - "8": { - text: "foo(args_0: number, args_1: string, args_2: string): void", - parameterCount: 3, - activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}, - parameterSpan: "args_2: string", - }, - }, - }, - { - title: "signatureHelpExplicitTypeArguments", - input: `declare function f(x: T, y: U): T; -f(/*1*/); -f(/*2*/); -f(/*3*/); -f(/*4*/); - -interface A { a: number } -interface B extends A { b: string } -declare function g(x: T, y: U, z: V): T; -declare function h(x: T, y: U, z: V): T; -declare function j(x: T, y: U, z: V): T; -g(/*5*/); -h(/*6*/); -j(/*7*/); -g(/*8*/); -h(/*9*/); -j(/*10*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(x: number, y: string): number", parameterCount: 2, activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}, parameterSpan: "x: number"}, - "2": {text: "f(x: boolean, y: string): boolean", parameterCount: 2, parameterSpan: "x: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - // too few -- fill in rest with default - "3": {text: "f(x: number, y: string): number", parameterCount: 2, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - // too many -- ignore extra type arguments - "4": {text: "f(x: number, y: string): number", parameterCount: 2, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - - // not matched signature and no type arguments - "5": {text: "g(x: unknown, y: unknown, z: B): unknown", parameterCount: 3, parameterSpan: "x: unknown", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "6": {text: "h(x: unknown, y: unknown, z: A): unknown", parameterCount: 3, parameterSpan: "x: unknown", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "7": {text: "j(x: unknown, y: unknown, z: B): unknown", parameterCount: 3, parameterSpan: "x: unknown", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - // not matched signature and too few type arguments - "8": {text: "g(x: number, y: unknown, z: B): number", parameterCount: 3, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "9": {text: "h(x: number, y: unknown, z: A): number", parameterCount: 3, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "10": {text: "j(x: number, y: unknown, z: B): number", parameterCount: 3, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpForOptionalMethods", - input: `interface Obj { - optionalMethod?: (current: any) => any; -}; - -const o: Obj = { - optionalMethod(/*1*/) { - return {}; - } -};`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "optionalMethod(current: any): any", parameterCount: 1, parameterSpan: "current: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpForSuperCalls", - input: `class A { } -class B extends A { } -class C extends B { - constructor() { - super(/*1*/ // sig help here? - } -} -class A2 { } -class B2 extends A2 { - constructor(x:number) {} -} -class C2 extends B2 { - constructor() { - super(/*2*/ // sig help here? - } -}`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "B(): B", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "B2(x: number): B2", parameterCount: 1, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpFunctionOverload", - input: `function functionOverload(); -function functionOverload(test: string); -function functionOverload(test?: string) { } -functionOverload(/*functionOverload1*/); -functionOverload(""/*functionOverload2*/);`, - expected: map[string]verifySignatureHelpOptions{ - "functionOverload1": {text: "functionOverload(): any", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "functionOverload2": {text: "functionOverload(test: string): any", parameterCount: 1, parameterSpan: "test: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpFunctionParameter", - input: `function parameterFunction(callback: (a: number, b: string) => void) { - callback(/*parameterFunction1*/5, /*parameterFunction2*/""); -}`, - expected: map[string]verifySignatureHelpOptions{ - "parameterFunction1": {text: "callback(a: number, b: string): void", parameterCount: 2, parameterSpan: "a: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "parameterFunction2": {text: "callback(a: number, b: string): void", parameterCount: 2, parameterSpan: "b: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpImplicitConstructor", - input: `class ImplicitConstructor { -} -var implicitConstructor = new ImplicitConstructor(/*1*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "ImplicitConstructor(): ImplicitConstructor", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpInCallback", - input: `declare function forEach(f: () => void); -forEach(/*1*/() => { -});`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "forEach(f: () => void): any", parameterCount: 1, parameterSpan: "f: () => void", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpIncompleteCalls", - input: `module IncompleteCalls { - class Foo { - public f1() { } - public f2(n: number): number { return 0; } - public f3(n: number, s: string) : string { return ""; } - } - var x = new Foo(); - x.f1(); - x.f2(5); - x.f3(5, ""); - x.f1(/*incompleteCalls1*/ - x.f2(5,/*incompleteCalls2*/ - x.f3(5,/*incompleteCalls3*/ -}`, - expected: map[string]verifySignatureHelpOptions{ - "incompleteCalls1": {text: "f1(): void", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "incompleteCalls2": {text: "f2(n: number): number", parameterCount: 1, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "incompleteCalls3": {text: "f3(n: number, s: string): string", parameterCount: 2, parameterSpan: "s: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpCompleteGenericsCall", - input: `function foo(x: number, callback: (x: T) => number) { -} -foo(/*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "foo(x: number, callback: (x: unknown) => number): void", parameterCount: 2, parameterSpan: "x: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpInference", - input: `declare function f(a: T, b: T, c: T): void; -f("x", /**/);`, - expected: map[string]verifySignatureHelpOptions{ - "": {text: `f(a: "x", b: "x", c: "x"): void`, parameterCount: 3, parameterSpan: `b: "x"`, activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpInParenthetical", - input: `class base { constructor (public n: number, public y: string) { } } -(new base(/*1*/ -(new base(0, /*2*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "base(n: number, y: string): base", parameterCount: 2, parameterSpan: "n: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "base(n: number, y: string): base", parameterCount: 2, parameterSpan: "y: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpLeadingRestTuple", - input: `export function leading(...args: [...names: string[], allCaps: boolean]): void { -} - -leading(/*1*/); -leading("ok", /*2*/); -leading("ok", "ok", /*3*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "leading(...names: string[], allCaps: boolean): void", parameterCount: 2, parameterSpan: "allCaps: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "2": {text: "leading(...names: string[], allCaps: boolean): void", parameterCount: 2, parameterSpan: "allCaps: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "3": {text: "leading(...names: string[], allCaps: boolean): void", parameterCount: 2, parameterSpan: "allCaps: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpNoArguments", - input: `function foo(n: number): string { -} -foo(/**/`, - expected: map[string]verifySignatureHelpOptions{ - "": {text: "foo(n: number): string", parameterCount: 1, parameterSpan: "n: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpObjectLiteral", - input: `var objectLiteral = { n: 5, s: "", f: (a: number, b: string) => "" }; -objectLiteral.f(/*objectLiteral1*/4, /*objectLiteral2*/"");`, - expected: map[string]verifySignatureHelpOptions{ - "objectLiteral1": {text: "f(a: number, b: string): string", parameterCount: 2, parameterSpan: "a: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "objectLiteral2": {text: "f(a: number, b: string): string", parameterCount: 2, parameterSpan: "b: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnNestedOverloads", - input: `declare function fn(x: string); -declare function fn(x: string, y: number); -declare function fn2(x: string); -declare function fn2(x: string, y: number); -fn('', fn2(/*1*/ -fn2('', fn2('',/*2*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fn2(x: string): any", parameterCount: 1, parameterSpan: "x: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "fn2(x: string, y: number): any", parameterCount: 2, parameterSpan: "y: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnOverloadOnConst", - input: `function x1(x: 'hi'); -function x1(y: 'bye'); -function x1(z: string); -function x1(a: any) { -} - -x1(''/*1*/); -x1('hi'/*2*/); -x1('bye'/*3*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: `x1(z: string): any`, parameterCount: 1, parameterSpan: "z: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: `x1(x: "hi"): any`, parameterCount: 1, parameterSpan: `x: "hi"`, activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "3": {text: `x1(y: "bye"): any`, parameterCount: 1, parameterSpan: `y: "bye"`, activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOnOverloads", - input: `declare function fn(x: string); -declare function fn(x: string, y: number); -fn(/*1*/ -fn('',/*2*/)`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fn(x: string): any", parameterCount: 1, parameterSpan: "x: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "fn(x: string, y: number): any", parameterCount: 2, parameterSpan: "y: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity1", - input: `declare function f(s: string); -declare function f(n: number); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean) -f(1/*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(n: number): any", parameterCount: 1, parameterSpan: "n: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity1_1", - input: `declare function f(s: string); -declare function f(n: number); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean) -f(1, /*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(n: number, b: boolean): any", parameterCount: 2, parameterSpan: "b: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity2", - input: `declare function f(s: string); -declare function f(n: number); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean); - -f(1/*1*/ var`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(n: number): any", parameterCount: 1, parameterSpan: "n: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity2_2", - input: `declare function f(s: string); -declare function f(n: number); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean); - -f(1, /*1*/var`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(n: number, b: boolean): any", parameterCount: 2, parameterSpan: "b: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity3_1", - input: `declare function f(); -declare function f(s: string); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean); - -f(/*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(): any", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOnOverloadsDifferentArity3_2", - input: `declare function f(); -declare function f(s: string); -declare function f(s: string, b: boolean); -declare function f(n: number, b: boolean); - -f(x, /*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f(s: string, b: boolean): any", parameterCount: 2, parameterSpan: "b: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpOnSuperWhenMembersAreNotResolved", - input: `class A { } -class B extends A { constructor(public x: string) { } } -class C extends B { - constructor() { - super(/*1*/ - } -}`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "B(x: string): B", parameterCount: 1, parameterSpan: "x: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOnTypePredicates", - input: `function f1(a: any): a is number {} -function f2(a: any): a is T {} -function f3(a: any, ...b): a is number {} -f1(/*1*/) -f2(/*2*/) -f3(/*3*/)`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f1(a: any): a is number", parameterCount: 1, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "f2(a: any): a is unknown", parameterCount: 1, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "3": {text: "f3(a: any, ...b: any[]): a is number", parameterCount: 2, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpOptionalCall", - input: `function fnTest(str: string, num: number) { } -fnTest?.(/*1*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fnTest(str: string, num: number): void", parameterCount: 2, parameterSpan: "str: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHepSimpleConstructorCall", - input: `class ConstructorCall { - constructor(str: string, num: number) { - } -} -var x = new ConstructorCall(/*constructorCall1*/1,/*constructorCall2*/2);`, - expected: map[string]verifySignatureHelpOptions{ - "constructorCall1": {text: "ConstructorCall(str: string, num: number): ConstructorCall", parameterCount: 2, parameterSpan: "str: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "constructorCall2": {text: "ConstructorCall(str: string, num: number): ConstructorCall", parameterCount: 2, parameterSpan: "num: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpSimpleFunctionCall", - input: `function functionCall(str: string, num: number) { -} -functionCall(/*functionCall1*/); -functionCall("", /*functionCall2*/1);`, - expected: map[string]verifySignatureHelpOptions{ - "functionCall1": {text: "functionCall(str: string, num: number): void", parameterCount: 2, parameterSpan: "str: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "functionCall2": {text: "functionCall(str: string, num: number): void", parameterCount: 2, parameterSpan: "num: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpSimpleSuperCall", - input: `class SuperCallBase { - constructor(b: boolean) { - } -} -class SuperCall extends SuperCallBase { - constructor() { - super(/*superCall*/); - } -}`, - expected: map[string]verifySignatureHelpOptions{ - "superCall": {text: "SuperCallBase(b: boolean): SuperCallBase", parameterCount: 1, parameterSpan: "b: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpSuperConstructorOverload", - input: `class SuperOverloadBase { - constructor(); - constructor(test: string); - constructor(test?: string) { - } -} -class SuperOverLoad1 extends SuperOverloadBase { - constructor() { - super(/*superOverload1*/); - } -} -class SuperOverLoad2 extends SuperOverloadBase { - constructor() { - super(""/*superOverload2*/); - } -}`, - expected: map[string]verifySignatureHelpOptions{ - "superOverload1": {text: "SuperOverloadBase(): SuperOverloadBase", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "superOverload2": {text: "SuperOverloadBase(test: string): SuperOverloadBase", parameterCount: 1, parameterSpan: "test: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpTrailingRestTuple", - input: `export function leading(allCaps: boolean, ...names: string[]): void { -} - -leading(/*1*/); -leading(false, /*2*/); -leading(false, "ok", /*3*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "leading(allCaps: boolean, ...names: string[]): void", parameterCount: 2, parameterSpan: "allCaps: boolean", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "leading(allCaps: boolean, ...names: string[]): void", parameterCount: 2, parameterSpan: "...names: string[]", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "3": {text: "leading(allCaps: boolean, ...names: string[]): void", parameterCount: 2, parameterSpan: "...names: string[]", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - }, - }, - { - title: "signatureHelpWithInvalidArgumentList1", - input: `function foo(a) { } -foo(hello my name /**/is`, - expected: map[string]verifySignatureHelpOptions{ - "": {text: "foo(a: any): void", parameterCount: 1, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - }, - }, - { - title: "signatureHelpAfterParameter", - input: `type Type = (a, b, c) => void -const a: Type = (a/*1*/, b/*2*/) => {} -const b: Type = function (a/*3*/, b/*4*/) {} -const c: Type = ({ /*5*/a: { b/*6*/ }}/*7*/ = { }/*8*/, [b/*9*/]/*10*/, .../*11*/c/*12*/) => {}`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "b: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "3": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "4": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "b: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "5": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "6": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "7": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "8": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "a: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "9": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "b: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "10": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "b: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "11": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "c: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - "12": {text: "Type(a: any, b: any, c: any): void", parameterCount: 3, parameterSpan: "c: any", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - }, - }, - { - title: "signaturehelpCallExpressionTuples", - input: `function fnTest(str: string, num: number) { } -declare function wrap(fn: (...a: A) => R) : (...a: A) => R; -var fnWrapped = wrap(fnTest); -fnWrapped(/*1*/'', /*2*/5); -function fnTestVariadic (str: string, ...num: number[]) { } -var fnVariadicWrapped = wrap(fnTestVariadic); -fnVariadicWrapped(/*3*/'', /*4*/5); -function fnNoParams () { } -var fnNoParamsWrapped = wrap(fnNoParams); -fnNoParamsWrapped(/*5*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fnWrapped(str: string, num: number): void", parameterCount: 2, parameterSpan: "str: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "fnWrapped(str: string, num: number): void", parameterCount: 2, parameterSpan: "num: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "3": {text: "fnVariadicWrapped(str: string, ...num: number[]): void", parameterCount: 2, parameterSpan: "str: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "4": {text: "fnVariadicWrapped(str: string, ...num: number[]): void", parameterCount: 2, parameterSpan: "...num: number[]", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "5": {text: "fnNoParamsWrapped(): void", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpConstructorCallParamProperties", - input: `class Circle { - constructor(private radius: number) { - } -} -var a = new Circle(/**/`, - expected: map[string]verifySignatureHelpOptions{ - "": {text: "Circle(radius: number): Circle", parameterCount: 1, parameterSpan: "radius: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpInRecursiveType", - input: `type Tail = - ((...args: T) => any) extends ((head: any, ...tail: infer R) => any) ? R : never; - -type Reverse = _Reverse; - -type _Reverse = { - 1: Result, - 0: _Reverse, 0>, -}[Source extends [] ? 1 : 0]; - -type Foo = Reverse<[0,/**/]>;`, - expected: map[string]verifySignatureHelpOptions{ - "": {text: "Reverse", parameterCount: 1, parameterSpan: "List extends any[]", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpRestArgs1", - input: `function fn(a: number, b: number, c: number) {} -const a = [1, 2] as const; -const b = [1] as const; - -fn(...a, /*1*/); -fn(/*2*/, ...a); - -fn(...b, /*3*/); -fn(/*4*/, ...b, /*5*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "c: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - "2": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "a: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "3": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "b: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "4": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "a: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "5": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "c: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - }, - }, - { - title: "signatureHelpSkippedArgs1", - input: `function fn(a: number, b: number, c: number) {} -fn(/*1*/, /*2*/, /*3*/, /*4*/, /*5*/);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "a: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "2": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "b: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "3": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "c: number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - "4": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(3))}}, - "5": {text: "fn(a: number, b: number, c: number): void", parameterCount: 3, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(4))}}, - }, - }, - { - title: "signatureHelpTypeArguments", - input: `declare function f(a: number, b: string, c: boolean): void; // ignored, not generic -declare function f(): void; -declare function f(): void; -declare function f(): void; -f(): void; - new(): void; - new(): void; -}; -new C(): void", parameterCount: 1, parameterSpan: "T extends number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "f1": {text: "f(): void", parameterCount: 2, parameterSpan: "U", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "f2": {text: "f(): void", parameterCount: 3, parameterSpan: "V extends string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - "C0": {text: "C(): void", parameterCount: 1, parameterSpan: "T extends number", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "C1": {text: "C(): void", parameterCount: 2, parameterSpan: "U", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "C2": {text: "C(): void", parameterCount: 3, parameterSpan: "V extends string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - }, - }, - { - title: "signatureHelpTypeArguments2", - input: `function f(a: number, b: string, c: boolean): void { } -f(a: number, b: string, c: boolean): void", parameterCount: 4, parameterSpan: "T", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - "f1": {text: "f(a: number, b: string, c: boolean): void", parameterCount: 4, parameterSpan: "U", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(1))}}, - "f2": {text: "f(a: number, b: string, c: boolean): void", parameterCount: 4, parameterSpan: "V", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(2))}}, - "f3": {text: "f(a: number, b: string, c: boolean): void", parameterCount: 4, parameterSpan: "W", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(3))}}, - }, - }, - { - title: "signatureHelpTypeParametersNotVariadic", - input: `declare function f(a: any, ...b: any[]): any; -f(1, 2);`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "f<>(a: any, ...b: any[]): any", parameterCount: 0, parameterSpan: "", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpWithUnknown", - input: `eval(\/*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "eval(x: string): any", parameterCount: 1, parameterSpan: "x: string", activeParameter: &lsproto.UintegerOrNull{Uinteger: ptrTo(uint32(0))}}, - }, - }, - { - title: "signatureHelpWithoutContext", - input: `let x = /*1*/`, - expected: map[string]verifySignatureHelpOptions{ - "1": {text: "", parameterCount: 0, parameterSpan: "", activeParameter: nil}, - }, - noContext: true, - }, - } - - for _, testCase := range testCases { - t.Run(testCase.title, func(t *testing.T) { - t.Parallel() - runSignatureHelpTest(t, testCase.input, testCase.expected, testCase.noContext) - }) - } -} - -func runSignatureHelpTest(t *testing.T, input string, expected map[string]verifySignatureHelpOptions, noContext bool) { - testData := fourslash.ParseTestData(t, input, "/mainFile.ts") - file := testData.Files[0].FileName() - markerPositions := testData.MarkerPositions - ctx := projecttestutil.WithRequestID(t.Context()) - languageService, done := createLanguageService(ctx, file, map[string]string{ - file: testData.Files[0].Content, - }) - defer done() - - var context *lsproto.SignatureHelpContext - if !noContext { - context = &lsproto.SignatureHelpContext{ - TriggerKind: lsproto.SignatureHelpTriggerKindInvoked, - TriggerCharacter: nil, - } - } - - ptrTrue := ptrTo(true) - capabilities := &lsproto.SignatureHelpClientCapabilities{ - SignatureInformation: &lsproto.ClientSignatureInformationOptions{ - ActiveParameterSupport: ptrTrue, - NoActiveParameterSupport: ptrTrue, - ParameterInformation: &lsproto.ClientSignatureParameterInformationOptions{ - LabelOffsetSupport: ptrTrue, - }, - }, - } - preferences := &ls.UserPreferences{} - for markerName, expectedResult := range expected { - marker, ok := markerPositions[markerName] - if !ok { - t.Fatalf("No marker found for '%s'", markerName) - } - rawResult, err := languageService.ProvideSignatureHelp(ctx, ls.FileNameToDocumentURI(file), marker.LSPosition, context, capabilities, preferences) - assert.NilError(t, err) - result := rawResult.SignatureHelp - if result == nil { - assert.Equal(t, expectedResult.text, "") - continue - } - assert.Equal(t, expectedResult.text, result.Signatures[*result.ActiveSignature].Label) - assert.Equal(t, expectedResult.parameterCount, len(*result.Signatures[*result.ActiveSignature].Parameters)) - assert.DeepEqual(t, expectedResult.activeParameter, result.ActiveParameter) - // Checking the parameter span that will be highlighted in the editor - if expectedResult.activeParameter != nil && expectedResult.activeParameter.Uinteger != nil && int(*expectedResult.activeParameter.Uinteger) < expectedResult.parameterCount { - assert.Equal(t, expectedResult.parameterSpan, *(*result.Signatures[*result.ActiveSignature].Parameters)[int(*result.ActiveParameter.Uinteger)].Label.String) - } - } -} diff --git a/internal/ls/untitled_test.go b/internal/ls/untitled_test.go deleted file mode 100644 index 9c6ddb197b..0000000000 --- a/internal/ls/untitled_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package ls_test - -import ( - "strings" - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "github.com/microsoft/typescript-go/internal/tspath" - "gotest.tools/v3/assert" -) - -func TestUntitledReferences(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - // First test the URI conversion functions to understand the issue - untitledURI := lsproto.DocumentUri("untitled:Untitled-2") - convertedFileName := ls.DocumentURIToFileName(untitledURI) - t.Logf("URI '%s' converts to filename '%s'", untitledURI, convertedFileName) - - backToURI := ls.FileNameToDocumentURI(convertedFileName) - t.Logf("Filename '%s' converts back to URI '%s'", convertedFileName, backToURI) - - if string(backToURI) != string(untitledURI) { - t.Errorf("Round-trip conversion failed: '%s' -> '%s' -> '%s'", untitledURI, convertedFileName, backToURI) - } - - // Create a test case that simulates how untitled files should work - testContent := `let x = 42; - -x - -x++;` - - // Use the converted filename that DocumentURIToFileName would produce - untitledFileName := convertedFileName // "^/untitled/ts-nul-authority/Untitled-2" - t.Logf("Would use untitled filename: %s", untitledFileName) - - // Set up the file system with an untitled file - - // But use a regular file first to see the current behavior - files := map[string]string{ - "/Untitled-2.ts": testContent, - } - - ctx := projecttestutil.WithRequestID(t.Context()) - service, done := createLanguageService(ctx, "/Untitled-2.ts", files) - defer done() - - // Test the filename that the source file reports - program := service.GetProgram() - sourceFile := program.GetSourceFile("/Untitled-2.ts") - t.Logf("SourceFile.FileName() returns: '%s'", sourceFile.FileName()) - - // Calculate position of 'x' on line 3 (zero-indexed line 2, character 0) - position := 13 // After "let x = 42;\n\n" - - // Call ProvideReferences using the test method - resp, err := service.TestProvideReferences("/Untitled-2.ts", position) - assert.NilError(t, err) - - refs := *resp.Locations - - // Log the results - t.Logf("Input file name: %s", "/Untitled-2.ts") - t.Logf("Number of references found: %d", len(refs)) - for i, ref := range refs { - t.Logf("Reference %d: URI=%s, Range=%+v", i+1, ref.Uri, ref.Range) - } - - // We expect to find 3 references - assert.Assert(t, len(refs) == 3, "Expected 3 references, got %d", len(refs)) - - // Also test definition using ProvideDefinition - uri := ls.FileNameToDocumentURI("/Untitled-2.ts") - lspPosition := lsproto.Position{Line: 2, Character: 0} - definition, err := service.ProvideDefinition(t.Context(), uri, lspPosition) - assert.NilError(t, err) - if definition.Locations != nil { - t.Logf("Definition found: %d locations", len(*definition.Locations)) - for i, loc := range *definition.Locations { - t.Logf("Definition %d: URI=%s, Range=%+v", i+1, loc.Uri, loc.Range) - } - } -} - -func TestUntitledFileNameDebugging(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - // Test the URI conversion flow - untitledURI := lsproto.DocumentUri("untitled:Untitled-2") - convertedFileName := ls.DocumentURIToFileName(untitledURI) - t.Logf("1. URI '%s' converts to filename '%s'", untitledURI, convertedFileName) - - // Test the path handling - currentDir := "/home/daniel/TypeScript" - path := tspath.ToPath(convertedFileName, currentDir, true) - t.Logf("2. ToPath('%s', '%s') returns: '%s'", convertedFileName, currentDir, string(path)) - - // Verify the path is NOT resolved against current directory - if strings.HasPrefix(string(path), currentDir) { - t.Errorf("Path was incorrectly resolved against current directory: %s", string(path)) - } - - // Test converting back to URI - backToURI := ls.FileNameToDocumentURI(string(path)) - t.Logf("3. Path '%s' converts back to URI '%s'", string(path), backToURI) - - if string(backToURI) != string(untitledURI) { - t.Errorf("Round-trip conversion failed: '%s' -> '%s' -> '%s'", untitledURI, string(path), backToURI) - } - - t.Logf("✅ Fix working: untitled paths are not resolved against current directory") -} - -func TestUntitledFileIntegration(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - // This test simulates the exact scenario from the issue: - // 1. VS Code sends untitled:Untitled-2 URI - // 2. References/definitions should return untitled:Untitled-2 URIs, not file:// URIs - - // Simulate exactly what happens in the LSP flow - originalURI := lsproto.DocumentUri("untitled:Untitled-2") - - // Step 1: URI gets converted to filename when file is opened - fileName := ls.DocumentURIToFileName(originalURI) - t.Logf("1. Opening file: URI '%s' -> fileName '%s'", originalURI, fileName) - - // Step 2: fileName gets processed through ToPath in project service - currentDir := "/home/daniel/TypeScript" // Current directory from the original issue - path := tspath.ToPath(fileName, currentDir, true) - t.Logf("2. Project service processes: fileName '%s' -> path '%s'", fileName, string(path)) - - // Step 3: Verify path is NOT corrupted by current directory resolution - if strings.HasPrefix(string(path), currentDir) { - t.Fatalf("❌ BUG: Path was incorrectly resolved against current directory: %s", string(path)) - } - - // Step 4: When references are found, the path gets converted back to URI - resultURI := ls.FileNameToDocumentURI(string(path)) - t.Logf("3. References return: path '%s' -> URI '%s'", string(path), resultURI) - - // Step 5: Verify the round-trip conversion works - if string(resultURI) != string(originalURI) { - t.Fatalf("❌ Round-trip failed: %s != %s", originalURI, resultURI) - } - - t.Logf("✅ SUCCESS: Untitled file URIs are preserved correctly") - t.Logf(" Original URI: %s", originalURI) - t.Logf(" Final URI: %s", resultURI) -} diff --git a/internal/lsp/lsproto/_generate/generate.mts b/internal/lsp/lsproto/_generate/generate.mts index 581ebafc8f..f9538d6ad7 100644 --- a/internal/lsp/lsproto/_generate/generate.mts +++ b/internal/lsp/lsproto/_generate/generate.mts @@ -520,6 +520,14 @@ function generateCode() { generateStructFields(structure.name, true); writeLine(""); + if (hasTextDocumentURI(structure)) { + // Generate TextDocumentURI method + writeLine(`func (s *${structure.name}) TextDocumentURI() DocumentUri {`); + writeLine(`\treturn s.TextDocument.Uri`); + writeLine(`}`); + writeLine(""); + } + // Generate UnmarshalJSONFrom method for structure validation const requiredProps = structure.properties?.filter(p => !p.optional) || []; if (requiredProps.length > 0) { @@ -872,6 +880,15 @@ function generateCode() { return parts.join(""); } +function hasTextDocumentURI(structure: Structure) { + return structure.properties?.some(p => + !p.optional && + p.name === "textDocument" && + p.type.kind === "reference" && + p.type.name === "TextDocumentIdentifier" + ); +} + /** * Main function */ diff --git a/internal/lsp/lsproto/lsp.go b/internal/lsp/lsproto/lsp.go index f8e8c5defc..dd172077c2 100644 --- a/internal/lsp/lsproto/lsp.go +++ b/internal/lsp/lsproto/lsp.go @@ -2,13 +2,62 @@ package lsproto import ( "fmt" + "net/url" + "strings" "github.com/go-json-experiment/json" "github.com/go-json-experiment/json/jsontext" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" ) type DocumentUri string // !!! +func (uri DocumentUri) FileName() string { + if strings.HasPrefix(string(uri), "file://") { + parsed := core.Must(url.Parse(string(uri))) + if parsed.Host != "" { + return "//" + parsed.Host + parsed.Path + } + return fixWindowsURIPath(parsed.Path) + } + + // Leave all other URIs escaped so we can round-trip them. + + scheme, path, ok := strings.Cut(string(uri), ":") + if !ok { + panic(fmt.Sprintf("invalid URI: %s", uri)) + } + + authority := "ts-nul-authority" + if rest, ok := strings.CutPrefix(path, "//"); ok { + authority, path, ok = strings.Cut(rest, "/") + if !ok { + panic(fmt.Sprintf("invalid URI: %s", uri)) + } + } + + return "^/" + scheme + "/" + authority + "/" + path +} + +func (uri DocumentUri) Path(useCaseSensitiveFileNames bool) tspath.Path { + fileName := uri.FileName() + return tspath.ToPath(fileName, "", useCaseSensitiveFileNames) +} + +func fixWindowsURIPath(path string) string { + if rest, ok := strings.CutPrefix(path, "/"); ok { + if volume, rest, ok := tspath.SplitVolumePath(rest); ok { + return volume + rest + } + } + return path +} + +type HasTextDocumentURI interface { + TextDocumentURI() DocumentUri +} + type URI string // !!! type Method string diff --git a/internal/lsp/lsproto/lsp_generated.go b/internal/lsp/lsproto/lsp_generated.go index eb993ea513..04892d51b9 100644 --- a/internal/lsp/lsproto/lsp_generated.go +++ b/internal/lsp/lsproto/lsp_generated.go @@ -28,6 +28,10 @@ type ImplementationParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *ImplementationParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*ImplementationParams)(nil) func (s *ImplementationParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -218,6 +222,10 @@ type TypeDefinitionParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *TypeDefinitionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*TypeDefinitionParams)(nil) func (s *TypeDefinitionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -497,6 +505,10 @@ type DocumentColorParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *DocumentColorParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentColorParams)(nil) func (s *DocumentColorParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -681,6 +693,10 @@ type ColorPresentationParams struct { Range Range `json:"range"` } +func (s *ColorPresentationParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*ColorPresentationParams)(nil) func (s *ColorPresentationParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -874,6 +890,10 @@ type FoldingRangeParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *FoldingRangeParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*FoldingRangeParams)(nil) func (s *FoldingRangeParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -1091,6 +1111,10 @@ type DeclarationParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *DeclarationParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DeclarationParams)(nil) func (s *DeclarationParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -1224,6 +1248,10 @@ type SelectionRangeParams struct { Positions []Position `json:"positions"` } +func (s *SelectionRangeParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*SelectionRangeParams)(nil) func (s *SelectionRangeParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -1496,6 +1524,10 @@ type CallHierarchyPrepareParams struct { WorkDoneToken *IntegerOrString `json:"workDoneToken,omitzero"` } +func (s *CallHierarchyPrepareParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*CallHierarchyPrepareParams)(nil) func (s *CallHierarchyPrepareParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -1993,6 +2025,10 @@ type SemanticTokensParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *SemanticTokensParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*SemanticTokensParams)(nil) func (s *SemanticTokensParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -2245,6 +2281,10 @@ type SemanticTokensDeltaParams struct { PreviousResultId string `json:"previousResultId"` } +func (s *SemanticTokensDeltaParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*SemanticTokensDeltaParams)(nil) func (s *SemanticTokensDeltaParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -2414,6 +2454,10 @@ type SemanticTokensRangeParams struct { Range Range `json:"range"` } +func (s *SemanticTokensRangeParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*SemanticTokensRangeParams)(nil) func (s *SemanticTokensRangeParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -2606,6 +2650,10 @@ type LinkedEditingRangeParams struct { WorkDoneToken *IntegerOrString `json:"workDoneToken,omitzero"` } +func (s *LinkedEditingRangeParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*LinkedEditingRangeParams)(nil) func (s *LinkedEditingRangeParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -3020,6 +3068,10 @@ type MonikerParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *MonikerParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*MonikerParams)(nil) func (s *MonikerParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -3224,6 +3276,10 @@ type TypeHierarchyPrepareParams struct { WorkDoneToken *IntegerOrString `json:"workDoneToken,omitzero"` } +func (s *TypeHierarchyPrepareParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*TypeHierarchyPrepareParams)(nil) func (s *TypeHierarchyPrepareParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -3602,6 +3658,10 @@ type InlineValueParams struct { Context *InlineValueContext `json:"context"` } +func (s *InlineValueParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*InlineValueParams)(nil) func (s *InlineValueParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -3741,6 +3801,10 @@ type InlayHintParams struct { Range Range `json:"range"` } +func (s *InlayHintParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*InlayHintParams)(nil) func (s *InlayHintParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -4009,6 +4073,10 @@ type DocumentDiagnosticParams struct { PreviousResultId *string `json:"previousResultId,omitzero"` } +func (s *DocumentDiagnosticParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentDiagnosticParams)(nil) func (s *DocumentDiagnosticParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -4747,6 +4815,10 @@ type InlineCompletionParams struct { Context *InlineCompletionContext `json:"context"` } +func (s *InlineCompletionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*InlineCompletionParams)(nil) func (s *InlineCompletionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -5994,6 +6066,10 @@ type DidCloseTextDocumentParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *DidCloseTextDocumentParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DidCloseTextDocumentParams)(nil) func (s *DidCloseTextDocumentParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -6043,6 +6119,10 @@ type DidSaveTextDocumentParams struct { Text *string `json:"text,omitzero"` } +func (s *DidSaveTextDocumentParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DidSaveTextDocumentParams)(nil) func (s *DidSaveTextDocumentParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -6148,6 +6228,10 @@ type WillSaveTextDocumentParams struct { Reason TextDocumentSaveReason `json:"reason"` } +func (s *WillSaveTextDocumentParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*WillSaveTextDocumentParams)(nil) func (s *WillSaveTextDocumentParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -6437,6 +6521,10 @@ type CompletionParams struct { Context *CompletionContext `json:"context,omitzero"` } +func (s *CompletionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*CompletionParams)(nil) func (s *CompletionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -6968,6 +7056,10 @@ type HoverParams struct { WorkDoneToken *IntegerOrString `json:"workDoneToken,omitzero"` } +func (s *HoverParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*HoverParams)(nil) func (s *HoverParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -7145,6 +7237,10 @@ type SignatureHelpParams struct { Context *SignatureHelpContext `json:"context,omitzero"` } +func (s *SignatureHelpParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*SignatureHelpParams)(nil) func (s *SignatureHelpParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -7374,6 +7470,10 @@ type DefinitionParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *DefinitionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DefinitionParams)(nil) func (s *DefinitionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -7502,6 +7602,10 @@ type ReferenceParams struct { Context *ReferenceContext `json:"context"` } +func (s *ReferenceParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*ReferenceParams)(nil) func (s *ReferenceParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -7637,6 +7741,10 @@ type DocumentHighlightParams struct { PartialResultToken *IntegerOrString `json:"partialResultToken,omitzero"` } +func (s *DocumentHighlightParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentHighlightParams)(nil) func (s *DocumentHighlightParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -7814,6 +7922,10 @@ type DocumentSymbolParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *DocumentSymbolParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentSymbolParams)(nil) func (s *DocumentSymbolParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -8172,6 +8284,10 @@ type CodeActionParams struct { Context *CodeActionContext `json:"context"` } +func (s *CodeActionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*CodeActionParams)(nil) func (s *CodeActionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -8744,6 +8860,10 @@ type CodeLensParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *CodeLensParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*CodeLensParams)(nil) func (s *CodeLensParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -8927,6 +9047,10 @@ type DocumentLinkParams struct { TextDocument TextDocumentIdentifier `json:"textDocument"` } +func (s *DocumentLinkParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentLinkParams)(nil) func (s *DocumentLinkParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9119,6 +9243,10 @@ type DocumentFormattingParams struct { Options *FormattingOptions `json:"options"` } +func (s *DocumentFormattingParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentFormattingParams)(nil) func (s *DocumentFormattingParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9240,6 +9368,10 @@ type DocumentRangeFormattingParams struct { Options *FormattingOptions `json:"options"` } +func (s *DocumentRangeFormattingParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentRangeFormattingParams)(nil) func (s *DocumentRangeFormattingParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9385,6 +9517,10 @@ type DocumentRangesFormattingParams struct { Options *FormattingOptions `json:"options"` } +func (s *DocumentRangesFormattingParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentRangesFormattingParams)(nil) func (s *DocumentRangesFormattingParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9468,6 +9604,10 @@ type DocumentOnTypeFormattingParams struct { Options *FormattingOptions `json:"options"` } +func (s *DocumentOnTypeFormattingParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*DocumentOnTypeFormattingParams)(nil) func (s *DocumentOnTypeFormattingParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9620,6 +9760,10 @@ type RenameParams struct { NewName string `json:"newName"` } +func (s *RenameParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*RenameParams)(nil) func (s *RenameParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -9755,6 +9899,10 @@ type PrepareRenameParams struct { WorkDoneToken *IntegerOrString `json:"workDoneToken,omitzero"` } +func (s *PrepareRenameParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*PrepareRenameParams)(nil) func (s *PrepareRenameParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { @@ -10472,6 +10620,10 @@ type TextDocumentPositionParams struct { Position Position `json:"position"` } +func (s *TextDocumentPositionParams) TextDocumentURI() DocumentUri { + return s.TextDocument.Uri +} + var _ json.UnmarshalerFrom = (*TextDocumentPositionParams)(nil) func (s *TextDocumentPositionParams) UnmarshalJSONFrom(dec *jsontext.Decoder) error { diff --git a/internal/lsp/server.go b/internal/lsp/server.go index baf7296b66..5802a2d1b2 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -6,12 +6,14 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" "runtime/debug" "slices" "sync" "sync/atomic" "syscall" + "time" "github.com/go-json-experiment/json" "github.com/microsoft/typescript-go/internal/collections" @@ -19,6 +21,8 @@ import ( "github.com/microsoft/typescript-go/internal/ls" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/project/ata" + "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/vfs" "golang.org/x/sync/errgroup" "golang.org/x/text/language" @@ -33,8 +37,7 @@ type ServerOptions struct { FS vfs.FS DefaultLibraryPath string TypingsLocation string - - ParsedFileCache project.ParsedFileCache + ParseCache *project.ParseCache } func NewServer(opts *ServerOptions) *Server { @@ -45,6 +48,7 @@ func NewServer(opts *ServerOptions) *Server { r: opts.In, w: opts.Out, stderr: opts.Err, + logger: logging.NewLogger(opts.Err), requestQueue: make(chan *lsproto.RequestMessage, 100), outgoingQueue: make(chan *lsproto.Message, 100), pendingClientRequests: make(map[lsproto.ID]pendingClientRequest), @@ -53,13 +57,12 @@ func NewServer(opts *ServerOptions) *Server { fs: opts.FS, defaultLibraryPath: opts.DefaultLibraryPath, typingsLocation: opts.TypingsLocation, - parsedFileCache: opts.ParsedFileCache, } } var ( - _ project.ServiceHost = (*Server)(nil) - _ project.Client = (*Server)(nil) + _ ata.NpmExecutor = (*Server)(nil) + _ project.Client = (*Server)(nil) ) type pendingClientRequest struct { @@ -124,6 +127,7 @@ type Server struct { stderr io.Writer + logger logging.Logger clientSeq atomic.Int32 requestQueue chan *lsproto.RequestMessage outgoingQueue chan *lsproto.Message @@ -143,58 +147,20 @@ type Server struct { watchEnabled bool watcherID atomic.Uint32 - watchers collections.SyncSet[project.WatcherHandle] - - logger *project.Logger - projectService *project.Service + watchers collections.SyncSet[project.WatcherID] - // enables tests to share a cache of parsed source files - parsedFileCache project.ParsedFileCache + session *project.Session // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support compilerOptionsForInferredProjects *core.CompilerOptions } -// FS implements project.ServiceHost. -func (s *Server) FS() vfs.FS { - return s.fs -} - -// DefaultLibraryPath implements project.ServiceHost. -func (s *Server) DefaultLibraryPath() string { - return s.defaultLibraryPath -} - -// TypingsLocation implements project.ServiceHost. -func (s *Server) TypingsLocation() string { - return s.typingsLocation -} - -// GetCurrentDirectory implements project.ServiceHost. -func (s *Server) GetCurrentDirectory() string { - return s.cwd -} - -// Trace implements project.ServiceHost. -func (s *Server) Trace(msg string) { - s.Log(msg) -} - -// Client implements project.ServiceHost. -func (s *Server) Client() project.Client { - if !s.watchEnabled { - return nil - } - return s -} - // WatchFiles implements project.Client. -func (s *Server) WatchFiles(ctx context.Context, watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { - watcherId := fmt.Sprintf("watcher-%d", s.watcherID.Add(1)) +func (s *Server) WatchFiles(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { _, err := s.sendRequest(ctx, lsproto.MethodClientRegisterCapability, &lsproto.RegistrationParams{ Registrations: []*lsproto.Registration{ { - Id: watcherId, + Id: string(id), Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles), RegisterOptions: ptrTo(any(lsproto.DidChangeWatchedFilesRegistrationOptions{ Watchers: watchers, @@ -203,21 +169,20 @@ func (s *Server) WatchFiles(ctx context.Context, watchers []*lsproto.FileSystemW }, }) if err != nil { - return "", fmt.Errorf("failed to register file watcher: %w", err) + return fmt.Errorf("failed to register file watcher: %w", err) } - handle := project.WatcherHandle(watcherId) - s.watchers.Add(handle) - return handle, nil + s.watchers.Add(id) + return nil } // UnwatchFiles implements project.Client. -func (s *Server) UnwatchFiles(ctx context.Context, handle project.WatcherHandle) error { - if s.watchers.Has(handle) { +func (s *Server) UnwatchFiles(ctx context.Context, id project.WatcherID) error { + if s.watchers.Has(id) { _, err := s.sendRequest(ctx, lsproto.MethodClientUnregisterCapability, &lsproto.UnregistrationParams{ Unregisterations: []*lsproto.Unregistration{ { - Id: string(handle), + Id: string(id), Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles), }, }, @@ -226,11 +191,11 @@ func (s *Server) UnwatchFiles(ctx context.Context, handle project.WatcherHandle) return fmt.Errorf("failed to unregister file watcher: %w", err) } - s.watchers.Delete(handle) + s.watchers.Delete(id) return nil } - return fmt.Errorf("no file watcher exists with ID %s", handle) + return fmt.Errorf("no file watcher exists with ID %s", id) } // RefreshDiagnostics implements project.Client. @@ -292,7 +257,7 @@ func (s *Server) readLoop(ctx context.Context) error { if s.initializeParams == nil && msg.Kind == lsproto.MessageKindRequest { req := msg.AsRequest() if req.Method == lsproto.MethodInitialize { - resp, err := s.handleInitialize(ctx, req.Params.(*lsproto.InitializeParams)) + resp, err := s.handleInitialize(ctx, req.Params.(*lsproto.InitializeParams), func() {}) if err != nil { return err } @@ -358,21 +323,6 @@ func (s *Server) dispatchLoop(ctx context.Context) error { } handle := func() { - defer func() { - if r := recover(); r != nil { - stack := debug.Stack() - s.Log("panic handling request", req.Method, r, string(stack)) - if isBlockingMethod(req.Method) { - lspExit() - } else { - if req.ID != nil { - s.sendError(req.ID, fmt.Errorf("%w: panic handling request %s: %v", lsproto.ErrInternalError, req.Method, r)) - } else { - s.Log("unhandled panic in notification", req.Method, r) - } - } - } - }() if err := s.handleRequestOrNotification(requestCtx, req); err != nil { if errors.Is(err, context.Canceled) { s.sendError(req.ID, lsproto.ErrRequestCancelled) @@ -493,19 +443,19 @@ var handlers = sync.OnceValue(func() handlerMap { registerNotificationHandler(handlers, lsproto.TextDocumentDidCloseInfo, (*Server).handleDidClose) registerNotificationHandler(handlers, lsproto.WorkspaceDidChangeWatchedFilesInfo, (*Server).handleDidChangeWatchedFiles) - registerRequestHandler(handlers, lsproto.TextDocumentDiagnosticInfo, (*Server).handleDocumentDiagnostic) - registerRequestHandler(handlers, lsproto.TextDocumentHoverInfo, (*Server).handleHover) - registerRequestHandler(handlers, lsproto.TextDocumentDefinitionInfo, (*Server).handleDefinition) - registerRequestHandler(handlers, lsproto.TextDocumentTypeDefinitionInfo, (*Server).handleTypeDefinition) - registerRequestHandler(handlers, lsproto.TextDocumentCompletionInfo, (*Server).handleCompletion) - registerRequestHandler(handlers, lsproto.TextDocumentReferencesInfo, (*Server).handleReferences) - registerRequestHandler(handlers, lsproto.TextDocumentImplementationInfo, (*Server).handleImplementations) - registerRequestHandler(handlers, lsproto.TextDocumentSignatureHelpInfo, (*Server).handleSignatureHelp) - registerRequestHandler(handlers, lsproto.TextDocumentFormattingInfo, (*Server).handleDocumentFormat) - registerRequestHandler(handlers, lsproto.TextDocumentRangeFormattingInfo, (*Server).handleDocumentRangeFormat) - registerRequestHandler(handlers, lsproto.TextDocumentOnTypeFormattingInfo, (*Server).handleDocumentOnTypeFormat) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDiagnosticInfo, (*Server).handleDocumentDiagnostic) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentHoverInfo, (*Server).handleHover) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDefinitionInfo, (*Server).handleDefinition) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentTypeDefinitionInfo, (*Server).handleTypeDefinition) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentCompletionInfo, (*Server).handleCompletion) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentReferencesInfo, (*Server).handleReferences) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentImplementationInfo, (*Server).handleImplementations) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentSignatureHelpInfo, (*Server).handleSignatureHelp) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentFormattingInfo, (*Server).handleDocumentFormat) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentRangeFormattingInfo, (*Server).handleDocumentRangeFormat) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentOnTypeFormattingInfo, (*Server).handleDocumentOnTypeFormat) + registerLanguageServiceDocumentRequestHandler(handlers, lsproto.TextDocumentDocumentSymbolInfo, (*Server).handleDocumentSymbol) registerRequestHandler(handlers, lsproto.WorkspaceSymbolInfo, (*Server).handleWorkspaceSymbol) - registerRequestHandler(handlers, lsproto.TextDocumentDocumentSymbolInfo, (*Server).handleDocumentSymbol) registerRequestHandler(handlers, lsproto.CompletionItemResolveInfo, (*Server).handleCompletionItemResolve) return handlers @@ -525,14 +475,16 @@ func registerNotificationHandler[Req any](handlers handlerMap, info lsproto.Noti } } -func registerRequestHandler[Req, Resp any](handlers handlerMap, info lsproto.RequestInfo[Req, Resp], fn func(*Server, context.Context, Req) (Resp, error)) { +func registerRequestHandler[Req, Resp any](handlers handlerMap, info lsproto.RequestInfo[Req, Resp], fn func(*Server, context.Context, Req, func()) (Resp, error)) { handlers[info.Method] = func(s *Server, ctx context.Context, req *lsproto.RequestMessage) error { var params Req // Ignore empty params. if req.Params != nil { params = req.Params.(Req) } - resp, err := fn(s, ctx, params) + resp, err := fn(s, ctx, params, func() { + s.recover(req) + }) if err != nil { return err } @@ -544,7 +496,43 @@ func registerRequestHandler[Req, Resp any](handlers handlerMap, info lsproto.Req } } -func (s *Server) handleInitialize(ctx context.Context, params *lsproto.InitializeParams) (lsproto.InitializeResponse, error) { +func registerLanguageServiceDocumentRequestHandler[Req lsproto.HasTextDocumentURI, Resp any](handlers handlerMap, info lsproto.RequestInfo[Req, Resp], fn func(*Server, context.Context, *ls.LanguageService, Req) (Resp, error)) { + handlers[info.Method] = func(s *Server, ctx context.Context, req *lsproto.RequestMessage) error { + var params Req + // Ignore empty params. + if req.Params != nil { + params = req.Params.(Req) + } + ls, err := s.session.GetLanguageService(ctx, params.TextDocumentURI()) + if err != nil { + return err + } + defer s.recover(req) + resp, err := fn(s, ctx, ls, params) + if err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + s.sendResult(req.ID, resp) + return nil + } +} + +func (s *Server) recover(req *lsproto.RequestMessage) { + if r := recover(); r != nil { + stack := debug.Stack() + s.Log("panic handling request", req.Method, r, string(stack)) + if req.ID != nil { + s.sendError(req.ID, fmt.Errorf("%w: panic handling request %s: %v", lsproto.ErrInternalError, req.Method, r)) + } else { + s.Log("unhandled panic in notification", req.Method, r) + } + } +} + +func (s *Server) handleInitialize(ctx context.Context, params *lsproto.InitializeParams, _ func()) (lsproto.InitializeResponse, error) { if s.initializeParams != nil { return nil, lsproto.ErrInvalidRequest } @@ -578,9 +566,7 @@ func (s *Server) handleInitialize(ctx context.Context, params *lsproto.Initializ OpenClose: ptrTo(true), Change: ptrTo(lsproto.TextDocumentSyncKindIncremental), Save: &lsproto.BooleanOrSaveOptions{ - SaveOptions: &lsproto.SaveOptions{ - IncludeText: ptrTo(true), - }, + Boolean: ptrTo(true), }, }, }, @@ -639,27 +625,31 @@ func (s *Server) handleInitialized(ctx context.Context, params *lsproto.Initiali s.watchEnabled = true } - s.logger = project.NewLogger([]io.Writer{s.stderr}, "" /*file*/, project.LogLevelVerbose) - s.projectService = project.NewService(s, project.ServiceOptions{ - Logger: s.logger, - WatchEnabled: s.watchEnabled, - PositionEncoding: s.positionEncoding, - TypingsInstallerOptions: project.TypingsInstallerOptions{ - ThrottleLimit: 5, - NpmInstall: project.NpmInstall, + s.session = project.NewSession(&project.SessionInit{ + Options: &project.SessionOptions{ + CurrentDirectory: s.cwd, + DefaultLibraryPath: s.defaultLibraryPath, + TypingsLocation: s.typingsLocation, + PositionEncoding: s.positionEncoding, + WatchEnabled: s.watchEnabled, + LoggingEnabled: true, + DebounceDelay: 500 * time.Millisecond, }, - ParsedFileCache: s.parsedFileCache, + FS: s.fs, + Logger: s.logger, + Client: s, + NpmExecutor: s, }) // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support if s.compilerOptionsForInferredProjects != nil { - s.projectService.SetCompilerOptionsForInferredProjects(s.compilerOptionsForInferredProjects) + s.session.DidChangeCompilerOptionsForInferredProjects(ctx, s.compilerOptionsForInferredProjects) } return nil } -func (s *Server) handleShutdown(ctx context.Context, params any) (lsproto.ShutdownResponse, error) { - s.projectService.Close() +func (s *Server) handleShutdown(ctx context.Context, params any, _ func()) (lsproto.ShutdownResponse, error) { + s.session.Close() return lsproto.ShutdownResponse{}, nil } @@ -668,46 +658,39 @@ func (s *Server) handleExit(ctx context.Context, params any) error { } func (s *Server) handleDidOpen(ctx context.Context, params *lsproto.DidOpenTextDocumentParams) error { - s.projectService.OpenFile(ls.DocumentURIToFileName(params.TextDocument.Uri), params.TextDocument.Text, ls.LanguageKindToScriptKind(params.TextDocument.LanguageId), "") + s.session.DidOpenFile(ctx, params.TextDocument.Uri, params.TextDocument.Version, params.TextDocument.Text, params.TextDocument.LanguageId) return nil } func (s *Server) handleDidChange(ctx context.Context, params *lsproto.DidChangeTextDocumentParams) error { - return s.projectService.ChangeFile(params.TextDocument, params.ContentChanges) + s.session.DidChangeFile(ctx, params.TextDocument.Uri, params.TextDocument.Version, params.ContentChanges) + return nil } func (s *Server) handleDidSave(ctx context.Context, params *lsproto.DidSaveTextDocumentParams) error { - s.projectService.MarkFileSaved(ls.DocumentURIToFileName(params.TextDocument.Uri), *params.Text) + s.session.DidSaveFile(ctx, params.TextDocument.Uri) return nil } func (s *Server) handleDidClose(ctx context.Context, params *lsproto.DidCloseTextDocumentParams) error { - s.projectService.CloseFile(ls.DocumentURIToFileName(params.TextDocument.Uri)) + s.session.DidCloseFile(ctx, params.TextDocument.Uri) return nil } func (s *Server) handleDidChangeWatchedFiles(ctx context.Context, params *lsproto.DidChangeWatchedFilesParams) error { - return s.projectService.OnWatchedFilesChanged(ctx, params.Changes) + s.session.DidChangeWatchedFiles(ctx, params.Changes) + return nil } -func (s *Server) handleDocumentDiagnostic(ctx context.Context, params *lsproto.DocumentDiagnosticParams) (lsproto.DocumentDiagnosticResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideDiagnostics(ctx, params.TextDocument.Uri) +func (s *Server) handleDocumentDiagnostic(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentDiagnosticParams) (lsproto.DocumentDiagnosticResponse, error) { + return ls.ProvideDiagnostics(ctx, params.TextDocument.Uri) } -func (s *Server) handleHover(ctx context.Context, params *lsproto.HoverParams) (lsproto.HoverResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideHover(ctx, params.TextDocument.Uri, params.Position) +func (s *Server) handleHover(ctx context.Context, ls *ls.LanguageService, params *lsproto.HoverParams) (lsproto.HoverResponse, error) { + return ls.ProvideHover(ctx, params.TextDocument.Uri, params.Position) } -func (s *Server) handleSignatureHelp(ctx context.Context, params *lsproto.SignatureHelpParams) (lsproto.SignatureHelpResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() +func (s *Server) handleSignatureHelp(ctx context.Context, languageService *ls.LanguageService, params *lsproto.SignatureHelpParams) (lsproto.SignatureHelpResponse, error) { return languageService.ProvideSignatureHelp( ctx, params.TextDocument.Uri, @@ -718,40 +701,25 @@ func (s *Server) handleSignatureHelp(ctx context.Context, params *lsproto.Signat ) } -func (s *Server) handleDefinition(ctx context.Context, params *lsproto.DefinitionParams) (lsproto.DefinitionResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideDefinition(ctx, params.TextDocument.Uri, params.Position) +func (s *Server) handleDefinition(ctx context.Context, ls *ls.LanguageService, params *lsproto.DefinitionParams) (lsproto.DefinitionResponse, error) { + return ls.ProvideDefinition(ctx, params.TextDocument.Uri, params.Position) } -func (s *Server) handleTypeDefinition(ctx context.Context, params *lsproto.TypeDefinitionParams) (lsproto.TypeDefinitionResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideTypeDefinition(ctx, params.TextDocument.Uri, params.Position) +func (s *Server) handleTypeDefinition(ctx context.Context, ls *ls.LanguageService, params *lsproto.TypeDefinitionParams) (lsproto.TypeDefinitionResponse, error) { + return ls.ProvideTypeDefinition(ctx, params.TextDocument.Uri, params.Position) } -func (s *Server) handleReferences(ctx context.Context, params *lsproto.ReferenceParams) (lsproto.ReferencesResponse, error) { +func (s *Server) handleReferences(ctx context.Context, ls *ls.LanguageService, params *lsproto.ReferenceParams) (lsproto.ReferencesResponse, error) { // findAllReferences - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideReferences(ctx, params) + return ls.ProvideReferences(ctx, params) } -func (s *Server) handleImplementations(ctx context.Context, params *lsproto.ImplementationParams) (lsproto.ImplementationResponse, error) { +func (s *Server) handleImplementations(ctx context.Context, ls *ls.LanguageService, params *lsproto.ImplementationParams) (lsproto.ImplementationResponse, error) { // goToImplementation - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideImplementations(ctx, params) + return ls.ProvideImplementations(ctx, params) } -func (s *Server) handleCompletion(ctx context.Context, params *lsproto.CompletionParams) (lsproto.CompletionResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() +func (s *Server) handleCompletion(ctx context.Context, languageService *ls.LanguageService, params *lsproto.CompletionParams) (lsproto.CompletionResponse, error) { // !!! get user preferences return languageService.ProvideCompletion( ctx, @@ -762,14 +730,16 @@ func (s *Server) handleCompletion(ctx context.Context, params *lsproto.Completio &ls.UserPreferences{}) } -func (s *Server) handleCompletionItemResolve(ctx context.Context, params *lsproto.CompletionItem) (lsproto.CompletionResolveResponse, error) { +func (s *Server) handleCompletionItemResolve(ctx context.Context, params *lsproto.CompletionItem, recoverAndSendError func()) (lsproto.CompletionResolveResponse, error) { data, err := ls.GetCompletionItemData(params) if err != nil { return nil, err } - _, project := s.projectService.EnsureDefaultProjectForFile(data.FileName) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() + languageService, err := s.session.GetLanguageService(ctx, ls.FileNameToDocumentURI(data.FileName)) + if err != nil { + return nil, err + } + defer recoverAndSendError() return languageService.ResolveCompletionItem( ctx, params, @@ -779,22 +749,16 @@ func (s *Server) handleCompletionItemResolve(ctx context.Context, params *lsprot ) } -func (s *Server) handleDocumentFormat(ctx context.Context, params *lsproto.DocumentFormattingParams) (lsproto.DocumentFormattingResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideFormatDocument( +func (s *Server) handleDocumentFormat(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentFormattingParams) (lsproto.DocumentFormattingResponse, error) { + return ls.ProvideFormatDocument( ctx, params.TextDocument.Uri, params.Options, ) } -func (s *Server) handleDocumentRangeFormat(ctx context.Context, params *lsproto.DocumentRangeFormattingParams) (lsproto.DocumentRangeFormattingResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideFormatDocumentRange( +func (s *Server) handleDocumentRangeFormat(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentRangeFormattingParams) (lsproto.DocumentRangeFormattingResponse, error) { + return ls.ProvideFormatDocumentRange( ctx, params.TextDocument.Uri, params.Options, @@ -802,11 +766,8 @@ func (s *Server) handleDocumentRangeFormat(ctx context.Context, params *lsproto. ) } -func (s *Server) handleDocumentOnTypeFormat(ctx context.Context, params *lsproto.DocumentOnTypeFormattingParams) (lsproto.DocumentOnTypeFormattingResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideFormatDocumentOnType( +func (s *Server) handleDocumentOnTypeFormat(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentOnTypeFormattingParams) (lsproto.DocumentOnTypeFormattingResponse, error) { + return ls.ProvideFormatDocumentOnType( ctx, params.TextDocument.Uri, params.Options, @@ -815,16 +776,16 @@ func (s *Server) handleDocumentOnTypeFormat(ctx context.Context, params *lsproto ) } -func (s *Server) handleWorkspaceSymbol(ctx context.Context, params *lsproto.WorkspaceSymbolParams) (lsproto.WorkspaceSymbolResponse, error) { - programs := core.Map(s.projectService.Projects(), (*project.Project).GetProgram) - return ls.ProvideWorkspaceSymbols(ctx, programs, s.projectService.Converters(), params.Query) +func (s *Server) handleWorkspaceSymbol(ctx context.Context, params *lsproto.WorkspaceSymbolParams, recoverAndSendError func()) (lsproto.WorkspaceSymbolResponse, error) { + snapshot, release := s.session.Snapshot() + defer release() + defer recoverAndSendError() + programs := core.Map(snapshot.ProjectCollection.Projects(), (*project.Project).GetProgram) + return ls.ProvideWorkspaceSymbols(ctx, programs, snapshot.Converters(), params.Query) } -func (s *Server) handleDocumentSymbol(ctx context.Context, params *lsproto.DocumentSymbolParams) (lsproto.DocumentSymbolResponse, error) { - project := s.projectService.EnsureDefaultProjectForURI(params.TextDocument.Uri) - languageService, done := project.GetLanguageServiceForRequest(ctx) - defer done() - return languageService.ProvideDocumentSymbols(ctx, params.TextDocument.Uri) +func (s *Server) handleDocumentSymbol(ctx context.Context, ls *ls.LanguageService, params *lsproto.DocumentSymbolParams) (lsproto.DocumentSymbolResponse, error) { + return ls.ProvideDocumentSymbols(ctx, params.TextDocument.Uri) } func (s *Server) Log(msg ...any) { @@ -832,13 +793,20 @@ func (s *Server) Log(msg ...any) { } // !!! temporary; remove when we have `handleDidChangeConfiguration`/implicit project config support -func (s *Server) SetCompilerOptionsForInferredProjects(options *core.CompilerOptions) { +func (s *Server) SetCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) { s.compilerOptionsForInferredProjects = options - if s.projectService != nil { - s.projectService.SetCompilerOptionsForInferredProjects(options) + if s.session != nil { + s.session.DidChangeCompilerOptionsForInferredProjects(ctx, options) } } +// NpmInstall implements ata.NpmExecutor +func (s *Server) NpmInstall(cwd string, args []string) ([]byte, error) { + cmd := exec.Command("npm", args...) + cmd.Dir = cwd + return cmd.Output() +} + func isBlockingMethod(method lsproto.Method) bool { switch method { case lsproto.MethodInitialize, diff --git a/internal/project/api.go b/internal/project/api.go new file mode 100644 index 0000000000..ea84b3adb5 --- /dev/null +++ b/internal/project/api.go @@ -0,0 +1,29 @@ +package project + +import ( + "context" + + "github.com/microsoft/typescript-go/internal/collections" +) + +func (s *Session) OpenProject(ctx context.Context, configFileName string) (*Project, error) { + fileChanges, overlays, ataChanges := s.flushChanges(ctx) + newSnapshot := s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + fileChanges: fileChanges, + ataChanges: ataChanges, + apiRequest: &APISnapshotRequest{ + OpenProjects: collections.NewSetFromItems(configFileName), + }, + }) + + if newSnapshot.apiError != nil { + return nil, newSnapshot.apiError + } + + project := newSnapshot.ProjectCollection.ConfiguredProject(s.toPath(configFileName)) + if project == nil { + panic("OpenProject request returned no error but project not present in snapshot") + } + + return project, nil +} diff --git a/internal/project/ata.go b/internal/project/ata.go deleted file mode 100644 index c457a62b80..0000000000 --- a/internal/project/ata.go +++ /dev/null @@ -1,594 +0,0 @@ -package project - -import ( - "fmt" - "os/exec" - "sync" - "sync/atomic" - - "github.com/go-json-experiment/json" - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/module" - "github.com/microsoft/typescript-go/internal/semver" - "github.com/microsoft/typescript-go/internal/tspath" -) - -type PendingRequest struct { - requestId int32 - packageNames []string - filteredTypings []string - currentlyCachedTypings []string - p *Project - typingsInfo *TypingsInfo -} - -type NpmInstallOperation func(string, []string) ([]byte, error) - -type TypingsInstallerStatus struct { - RequestId int32 - Project *Project - Status string -} - -type TypingsInstallerOptions struct { - // !!! sheetal strada params to keep or not - // const typingSafeListLocation = ts.server.findArgument(ts.server.Arguments.TypingSafeListLocation); - // const typesMapLocation = ts.server.findArgument(ts.server.Arguments.TypesMapLocation); - // const npmLocation = ts.server.findArgument(ts.server.Arguments.NpmLocation); - // const validateDefaultNpmLocation = ts.server.hasArgument(ts.server.Arguments.ValidateDefaultNpmLocation); - ThrottleLimit int - - // For testing - NpmInstall NpmInstallOperation - InstallStatus chan TypingsInstallerStatus -} - -type TypingsInstaller struct { - TypingsLocation string - options *TypingsInstallerOptions - - initOnce sync.Once - - packageNameToTypingLocation collections.SyncMap[string, *CachedTyping] - missingTypingsSet collections.SyncMap[string, bool] - - typesRegistry map[string]map[string]string - - installRunCount atomic.Int32 - inFlightRequestCount int - pendingRunRequests []*PendingRequest - pendingRunRequestsMu sync.Mutex -} - -func (ti *TypingsInstaller) PendingRunRequestsCount() int { - ti.pendingRunRequestsMu.Lock() - defer ti.pendingRunRequestsMu.Unlock() - return len(ti.pendingRunRequests) -} - -func (ti *TypingsInstaller) IsKnownTypesPackageName(p *Project, name string) bool { - // We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package. - validationResult, _, _ := ValidatePackageName(name) - if validationResult != NameOk { - return false - } - // Strada did this lazily - is that needed here to not waiting on and returning false on first request - ti.init(p) - _, ok := ti.typesRegistry[name] - return ok -} - -// !!! sheetal currently we use latest instead of core.VersionMajorMinor() -const TsVersionToUse = "latest" - -func (ti *TypingsInstaller) InstallPackage(p *Project, fileName string, packageName string) { - cwd, ok := tspath.ForEachAncestorDirectory(tspath.GetDirectoryPath(fileName), func(directory string) (string, bool) { - if p.FS().FileExists(tspath.CombinePaths(directory, "package.json")) { - return directory, true - } - return "", false - }) - if !ok { - cwd = p.GetCurrentDirectory() - } - if cwd != "" { - go ti.installWorker(p, -1, []string{packageName}, cwd, func( - p *Project, - requestId int32, - packageNames []string, - success bool, - ) { - // !!! sheetal events to send - // const message = success ? - // - // `Package ${packageName} installed.` : - // `There was an error installing ${packageName}.`; - // - // const response: PackageInstalledResponse = { - // kind: ActionPackageInstalled, - // projectName, - // id, - // success, - // message, - // }; - // - - // this.sendResponse(response); - // // The behavior is the same as for setTypings, so send the same event. - // this.event(response, "setTypings"); -- Used same event name - do we need it ? - }) - } else { - // !!! sheetal events to send - // const response: PackageInstalledResponse = { - // kind: ActionPackageInstalled, - // projectName, - // id, - // success: false, - // message: "Could not determine a project root path.", - // }; - // this.sendResponse(response); - // // The behavior is the same as for setTypings, so send the same event. - // this.event(response, "setTypings"); -- Used same event name - do we need it ? - } -} - -func (ti *TypingsInstaller) EnqueueInstallTypingsRequest(p *Project, typingsInfo *TypingsInfo) { - // because we arent using buffers, no need to throttle for requests here - p.Log("ATA:: Got install request for: " + p.Name()) - go ti.discoverAndInstallTypings( - p, - typingsInfo, - p.GetFileNames( /*excludeFilesFromExternalLibraries*/ true /*excludeConfigFiles*/, true), - p.GetCurrentDirectory(), - ) //.concat(project.getExcludedFiles()) // !!! sheetal we dont have excluded files in project yet -} - -func (ti *TypingsInstaller) discoverAndInstallTypings(p *Project, typingsInfo *TypingsInfo, fileNames []string, projectRootPath string) { - ti.init(p) - - cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( - p.FS(), - p.Log, - typingsInfo, - fileNames, - projectRootPath, - &ti.packageNameToTypingLocation, - ti.typesRegistry, - ) - - // start watching files - p.WatchTypingLocations(filesToWatch) - - requestId := ti.installRunCount.Add(1) - // install typings - if len(newTypingNames) > 0 { - filteredTypings := ti.filterTypings(p, newTypingNames) - if len(filteredTypings) != 0 { - ti.installTypings(p, typingsInfo, requestId, cachedTypingPaths, filteredTypings) - return - } - p.Log("ATA:: All typings are known to be missing or invalid - no need to install more typings") - } else { - p.Log("ATA:: No new typings were requested as a result of typings discovery") - } - p.UpdateTypingFiles(typingsInfo, cachedTypingPaths) - // !!! sheetal events to send - // this.event(response, "setTypings"); - - if ti.options.InstallStatus != nil { - ti.options.InstallStatus <- TypingsInstallerStatus{ - RequestId: requestId, - Project: p, - Status: fmt.Sprintf("Skipped %d typings", len(newTypingNames)), - } - } -} - -func (ti *TypingsInstaller) installTypings( - p *Project, - typingsInfo *TypingsInfo, - requestId int32, - currentlyCachedTypings []string, - filteredTypings []string, -) { - // !!! sheetal events to send - // send progress event - // this.sendResponse({ - // kind: EventBeginInstallTypes, - // eventId: requestId, - // typingsInstallerVersion: version, - // projectName: req.projectName, - // } as BeginInstallTypes); - - // const body: protocol.BeginInstallTypesEventBody = { - // eventId: response.eventId, - // packages: response.packagesToInstall, - // }; - // const eventName: protocol.BeginInstallTypesEventName = "beginInstallTypes"; - // this.event(body, eventName); - - scopedTypings := make([]string, len(filteredTypings)) - for i, packageName := range filteredTypings { - scopedTypings[i] = fmt.Sprintf("@types/%s@%s", packageName, TsVersionToUse) // @tscore.VersionMajorMinor) // This is normally @tsVersionMajorMinor but for now lets use latest - } - - request := &PendingRequest{ - requestId: requestId, - packageNames: scopedTypings, - filteredTypings: filteredTypings, - currentlyCachedTypings: currentlyCachedTypings, - p: p, - typingsInfo: typingsInfo, - } - ti.pendingRunRequestsMu.Lock() - if ti.inFlightRequestCount < ti.options.ThrottleLimit { - ti.inFlightRequestCount++ - ti.pendingRunRequestsMu.Unlock() - ti.invokeRoutineToInstallTypings(request) - } else { - ti.pendingRunRequests = append(ti.pendingRunRequests, request) - ti.pendingRunRequestsMu.Unlock() - } -} - -func (ti *TypingsInstaller) invokeRoutineToInstallTypings( - request *PendingRequest, -) { - go ti.installWorker( - request.p, - request.requestId, - request.packageNames, - ti.TypingsLocation, - func( - p *Project, - requestId int32, - packageNames []string, - success bool, - ) { - if success { - p.Logf("ATA:: Installed typings %v", packageNames) - var installedTypingFiles []string - resolver := module.NewResolver(p, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") - for _, packageName := range request.filteredTypings { - typingFile := ti.typingToFileName(resolver, packageName) - if typingFile == "" { - ti.missingTypingsSet.Store(packageName, true) - continue - } - - // packageName is guaranteed to exist in typesRegistry by filterTypings - distTags := ti.typesRegistry[packageName] - useVersion, ok := distTags["ts"+core.VersionMajorMinor()] - if !ok { - useVersion = distTags["latest"] - } - newVersion := semver.MustParse(useVersion) - newTyping := &CachedTyping{TypingsLocation: typingFile, Version: newVersion} - ti.packageNameToTypingLocation.Store(packageName, newTyping) - installedTypingFiles = append(installedTypingFiles, typingFile) - } - p.Logf("ATA:: Installed typing files %v", installedTypingFiles) - p.UpdateTypingFiles(request.typingsInfo, append(request.currentlyCachedTypings, installedTypingFiles...)) - // DO we really need these events - // this.event(response, "setTypings"); - } else { - p.Logf("ATA:: install request failed, marking packages as missing to prevent repeated requests: %v", request.filteredTypings) - for _, typing := range request.filteredTypings { - ti.missingTypingsSet.Store(typing, true) - } - } - - // !!! sheetal events to send - // const response: EndInstallTypes = { - // kind: EventEndInstallTypes, - // eventId: requestId, - // projectName: req.projectName, - // packagesToInstall: scopedTypings, - // installSuccess: ok, - // typingsInstallerVersion: version, - // }; - // this.sendResponse(response); - - // if (this.telemetryEnabled) { - // const body: protocol.TypingsInstalledTelemetryEventBody = { - // telemetryEventName: "typingsInstalled", - // payload: { - // installedPackages: response.packagesToInstall.join(","), - // installSuccess: response.installSuccess, - // typingsInstallerVersion: response.typingsInstallerVersion, - // }, - // }; - // const eventName: protocol.TelemetryEventName = "telemetry"; - // this.event(body, eventName); - // } - - // const body: protocol.EndInstallTypesEventBody = { - // eventId: response.eventId, - // packages: response.packagesToInstall, - // success: response.installSuccess, - // }; - // const eventName: protocol.EndInstallTypesEventName = "endInstallTypes"; - // this.event(body, eventName); - - if ti.options.InstallStatus != nil { - ti.options.InstallStatus <- TypingsInstallerStatus{ - RequestId: requestId, - Project: p, - Status: core.IfElse(success, "Success", "Fail"), - } - } - - ti.pendingRunRequestsMu.Lock() - pendingRequestsCount := len(ti.pendingRunRequests) - var nextRequest *PendingRequest - if pendingRequestsCount == 0 { - ti.inFlightRequestCount-- - } else { - nextRequest = ti.pendingRunRequests[0] - if pendingRequestsCount == 1 { - ti.pendingRunRequests = nil - } else { - ti.pendingRunRequests[0] = nil // ensure the request is GC'd - ti.pendingRunRequests = ti.pendingRunRequests[1:] - } - } - ti.pendingRunRequestsMu.Unlock() - if nextRequest != nil { - ti.invokeRoutineToInstallTypings(nextRequest) - } - }, - ) -} - -func (ti *TypingsInstaller) installWorker( - p *Project, - requestId int32, - packageNames []string, - cwd string, - onRequestComplete func( - p *Project, - requestId int32, - packageNames []string, - success bool, - ), -) { - p.Logf("ATA:: #%d with cwd: %s arguments: %v", requestId, cwd, packageNames) - hasError := InstallNpmPackages(packageNames, func(packageNames []string, hasError *atomic.Bool) { - var npmArgs []string - npmArgs = append(npmArgs, "install", "--ignore-scripts") - npmArgs = append(npmArgs, packageNames...) - npmArgs = append(npmArgs, "--save-dev", "--user-agent=\"typesInstaller/"+core.Version()+"\"") - output, err := ti.options.NpmInstall(cwd, npmArgs) - if err != nil { - p.Logf("ATA:: Output is: %s", output) - hasError.Store(true) - } - }) - p.Logf("TI:: npm install #%d completed", requestId) - onRequestComplete(p, requestId, packageNames, !hasError) -} - -func InstallNpmPackages( - packageNames []string, - installPackages func(packages []string, hasError *atomic.Bool), -) bool { - var hasError atomic.Bool - hasError.Store(false) - - wg := core.NewWorkGroup(false) - currentCommandStart := 0 - currentCommandEnd := 0 - currentCommandSize := 100 - for _, packageName := range packageNames { - currentCommandSize = currentCommandSize + len(packageName) + 1 - if currentCommandSize < 8000 { - currentCommandEnd++ - } else { - packages := packageNames[currentCommandStart:currentCommandEnd] - wg.Queue(func() { - installPackages(packages, &hasError) - }) - currentCommandStart = currentCommandEnd - currentCommandSize = 100 + len(packageName) + 1 - currentCommandEnd++ - } - } - wg.Queue(func() { - installPackages(packageNames[currentCommandStart:currentCommandEnd], &hasError) - }) - wg.RunAndWait() - return hasError.Load() -} - -func (ti *TypingsInstaller) filterTypings( - p *Project, - typingsToInstall []string, -) []string { - var result []string - for _, typing := range typingsToInstall { - typingKey := module.MangleScopedPackageName(typing) - if _, ok := ti.missingTypingsSet.Load(typingKey); ok { - p.Logf("ATA:: '%s':: '%s' is in missingTypingsSet - skipping...", typing, typingKey) - continue - } - validationResult, name, isScopeName := ValidatePackageName(typing) - if validationResult != NameOk { - // add typing name to missing set so we won't process it again - ti.missingTypingsSet.Store(typingKey, true) - p.Log("ATA:: " + RenderPackageNameValidationFailure(typing, validationResult, name, isScopeName)) - continue - } - typesRegistryEntry, ok := ti.typesRegistry[typingKey] - if !ok { - p.Logf("ATA:: '%s':: Entry for package '%s' does not exist in local types registry - skipping...", typing, typingKey) - continue - } - if typingLocation, ok := ti.packageNameToTypingLocation.Load(typingKey); ok && IsTypingUpToDate(typingLocation, typesRegistryEntry) { - p.Logf("ATA:: '%s':: '%s' already has an up-to-date typing - skipping...", typing, typingKey) - continue - } - result = append(result, typingKey) - } - return result -} - -func (ti *TypingsInstaller) init(p *Project) { - ti.initOnce.Do(func() { - p.Log("ATA:: Global cache location '" + ti.TypingsLocation + "'") //, safe file path '" + safeListPath + "', types map path '" + typesMapLocation + "`") - ti.processCacheLocation(p) - - // !!! sheetal handle npm path here if we would support it - // // If the NPM path contains spaces and isn't wrapped in quotes, do so. - // if (this.npmPath.includes(" ") && this.npmPath[0] !== `"`) { - // this.npmPath = `"${this.npmPath}"`; - // } - // if (this.log.isEnabled()) { - // this.log.writeLine(`Process id: ${process.pid}`); - // this.log.writeLine(`NPM location: ${this.npmPath} (explicit '${ts.server.Arguments.NpmLocation}' ${npmLocation === undefined ? "not " : ""} provided)`); - // this.log.writeLine(`validateDefaultNpmLocation: ${validateDefaultNpmLocation}`); - // } - - ti.ensureTypingsLocationExists(p) - p.Log("ATA:: Updating types-registry@latest npm package...") - if _, err := ti.options.NpmInstall(ti.TypingsLocation, []string{"install", "--ignore-scripts", "types-registry@latest"}); err == nil { - p.Log("ATA:: Updated types-registry npm package") - } else { - p.Logf("ATA:: Error updating types-registry package: %v", err) - // !!! sheetal events to send - // // store error info to report it later when it is known that server is already listening to events from typings installer - // this.delayedInitializationError = { - // kind: "event::initializationFailed", - // message: (e as Error).message, - // stack: (e as Error).stack, - // }; - - // const body: protocol.TypesInstallerInitializationFailedEventBody = { - // message: response.message, - // }; - // const eventName: protocol.TypesInstallerInitializationFailedEventName = "typesInstallerInitializationFailed"; - // this.event(body, eventName); - } - - ti.typesRegistry = ti.loadTypesRegistryFile(p) - }) -} - -type NpmConfig struct { - DevDependencies map[string]any `json:"devDependencies"` -} - -type NpmDependecyEntry struct { - Version string `json:"version"` -} -type NpmLock struct { - Dependencies map[string]NpmDependecyEntry `json:"dependencies"` - Packages map[string]NpmDependecyEntry `json:"packages"` -} - -func (ti *TypingsInstaller) processCacheLocation(p *Project) { - p.Log("ATA:: Processing cache location " + ti.TypingsLocation) - packageJson := tspath.CombinePaths(ti.TypingsLocation, "package.json") - packageLockJson := tspath.CombinePaths(ti.TypingsLocation, "package-lock.json") - p.Log("ATA:: Trying to find '" + packageJson + "'...") - if p.FS().FileExists(packageJson) && p.FS().FileExists((packageLockJson)) { - var npmConfig NpmConfig - npmConfigContents := parseNpmConfigOrLock(p, packageJson, &npmConfig) - var npmLock NpmLock - npmLockContents := parseNpmConfigOrLock(p, packageLockJson, &npmLock) - - p.Log("ATA:: Loaded content of " + packageJson + ": " + npmConfigContents) - p.Log("ATA:: Loaded content of " + packageLockJson + ": " + npmLockContents) - - // !!! sheetal strada uses Node10 - resolver := module.NewResolver(p, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") - if npmConfig.DevDependencies != nil && (npmLock.Packages != nil || npmLock.Dependencies != nil) { - for key := range npmConfig.DevDependencies { - npmLockValue, npmLockValueExists := npmLock.Packages["node_modules/"+key] - if !npmLockValueExists { - npmLockValue, npmLockValueExists = npmLock.Dependencies[key] - if !npmLockValueExists { - // if package in package.json but not package-lock.json, skip adding to cache so it is reinstalled on next use - continue - } - } - // key is @types/ - packageName := tspath.GetBaseFileName(key) - if packageName == "" { - continue - } - typingFile := ti.typingToFileName(resolver, packageName) - if typingFile == "" { - ti.missingTypingsSet.Store(packageName, true) - continue - } - if existingTypingFile, existingTypingsFilePresent := ti.packageNameToTypingLocation.Load(packageName); existingTypingsFilePresent { - if existingTypingFile.TypingsLocation == typingFile { - continue - } - p.Log("ATA:: New typing for package " + packageName + " from " + typingFile + " conflicts with existing typing file " + existingTypingFile.TypingsLocation) - } - p.Log("ATA:: Adding entry into typings cache: " + packageName + " => " + typingFile) - version := npmLockValue.Version - if version == "" { - continue - } - - newTyping := &CachedTyping{ - TypingsLocation: typingFile, - Version: semver.MustParse(version), - } - ti.packageNameToTypingLocation.Store(packageName, newTyping) - } - } - } - p.Log("ATA:: Finished processing cache location " + ti.TypingsLocation) -} - -func parseNpmConfigOrLock[T NpmConfig | NpmLock](p *Project, location string, config *T) string { - contents, _ := p.FS().ReadFile(location) - _ = json.Unmarshal([]byte(contents), config) - return contents -} - -func (ti *TypingsInstaller) ensureTypingsLocationExists(p *Project) { - npmConfigPath := tspath.CombinePaths(ti.TypingsLocation, "package.json") - p.Log("ATA:: Npm config file: " + npmConfigPath) - - if !p.FS().FileExists(npmConfigPath) { - p.Logf("ATA:: Npm config file: '%s' is missing, creating new one...", npmConfigPath) - err := p.FS().WriteFile(npmConfigPath, "{ \"private\": true }", false) - if err != nil { - p.Logf("ATA:: Npm config file write failed: %v", err) - } - } -} - -func (ti *TypingsInstaller) typingToFileName(resolver *module.Resolver, packageName string) string { - result := resolver.ResolveModuleName(packageName, tspath.CombinePaths(ti.TypingsLocation, "index.d.ts"), core.ModuleKindNone, nil) - return result.ResolvedFileName -} - -func (ti *TypingsInstaller) loadTypesRegistryFile(p *Project) map[string]map[string]string { - typesRegistryFile := tspath.CombinePaths(ti.TypingsLocation, "node_modules/types-registry/index.json") - typesRegistryFileContents, ok := p.FS().ReadFile(typesRegistryFile) - if ok { - var entries map[string]map[string]map[string]string - err := json.Unmarshal([]byte(typesRegistryFileContents), &entries) - if err == nil { - if typesRegistry, ok := entries["entries"]; ok { - return typesRegistry - } - } - p.Logf("ATA:: Error when loading types registry file '%s': %v", typesRegistryFile, err) - } else { - p.Logf("ATA:: Error reading types registry file '%s'", typesRegistryFile) - } - return map[string]map[string]string{} -} - -func NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { - cmd := exec.Command("npm", npmInstallArgs...) - cmd.Dir = cwd - return cmd.Output() -} diff --git a/internal/project/ata/ata.go b/internal/project/ata/ata.go new file mode 100644 index 0000000000..c6165a9089 --- /dev/null +++ b/internal/project/ata/ata.go @@ -0,0 +1,477 @@ +package ata + +import ( + "context" + "errors" + "fmt" + "slices" + "sync" + "sync/atomic" + + "github.com/go-json-experiment/json" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/module" + "github.com/microsoft/typescript-go/internal/project/logging" + "github.com/microsoft/typescript-go/internal/semver" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" +) + +type TypingsInfo struct { + TypeAcquisition *core.TypeAcquisition + CompilerOptions *core.CompilerOptions + UnresolvedImports *collections.Set[string] +} + +func (ti TypingsInfo) Equals(other TypingsInfo) bool { + return ti.TypeAcquisition.Equals(other.TypeAcquisition) && + ti.CompilerOptions.GetAllowJS() == other.CompilerOptions.GetAllowJS() && + ti.UnresolvedImports.Equals(other.UnresolvedImports) +} + +type CachedTyping struct { + TypingsLocation string + Version *semver.Version +} + +type TypingsInstallerOptions struct { + TypingsLocation string + ThrottleLimit int +} + +type NpmExecutor interface { + NpmInstall(cwd string, args []string) ([]byte, error) +} + +type TypingsInstallerHost interface { + NpmExecutor + module.ResolutionHost +} + +type TypingsInstaller struct { + typingsLocation string + host TypingsInstallerHost + + initOnce sync.Once + + packageNameToTypingLocation collections.SyncMap[string, *CachedTyping] + missingTypingsSet collections.SyncMap[string, bool] + + typesRegistry map[string]map[string]string + + installRunCount atomic.Int32 + concurrencySemaphore chan struct{} +} + +func NewTypingsInstaller(options *TypingsInstallerOptions, host TypingsInstallerHost) *TypingsInstaller { + return &TypingsInstaller{ + typingsLocation: options.TypingsLocation, + host: host, + concurrencySemaphore: make(chan struct{}, options.ThrottleLimit), + } +} + +func (ti *TypingsInstaller) IsKnownTypesPackageName(projectID tspath.Path, name string, fs vfs.FS, logger logging.Logger) bool { + // We want to avoid looking this up in the registry as that is expensive. So first check that it's actually an NPM package. + validationResult, _, _ := ValidatePackageName(name) + if validationResult != NameOk { + return false + } + // Strada did this lazily - is that needed here to not waiting on and returning false on first request + ti.init(string(projectID), fs, logger) + _, ok := ti.typesRegistry[name] + return ok +} + +// !!! sheetal currently we use latest instead of core.VersionMajorMinor() +const tsVersionToUse = "latest" + +type TypingsInstallRequest struct { + ProjectID tspath.Path + TypingsInfo *TypingsInfo + FileNames []string + ProjectRootPath string + CompilerOptions *core.CompilerOptions + CurrentDirectory string + GetScriptKind func(string) core.ScriptKind + FS vfs.FS + Logger logging.Logger +} + +func (ti *TypingsInstaller) InstallTypings(request *TypingsInstallRequest) ([]string, error) { + // because we arent using buffers, no need to throttle for requests here + request.Logger.Log("ATA:: Got install request for: " + string(request.ProjectID)) + typingsFiles, err := ti.discoverAndInstallTypings(request) + if err == nil { + slices.Sort(typingsFiles) + } + return typingsFiles, err +} + +func (ti *TypingsInstaller) discoverAndInstallTypings(request *TypingsInstallRequest) ([]string, error) { + ti.init(string(request.ProjectID), request.FS, request.Logger) + + cachedTypingPaths, newTypingNames, filesToWatch := DiscoverTypings( + request.FS, + request.Logger, + request.TypingsInfo, + request.FileNames, + request.ProjectRootPath, + &ti.packageNameToTypingLocation, + ti.typesRegistry, + ) + + // !!! + if len(filesToWatch) > 0 { + request.Logger.Log(fmt.Sprintf("ATA:: Would watch typing locations: %v", filesToWatch)) + } + + requestId := ti.installRunCount.Add(1) + // install typings + if len(newTypingNames) > 0 { + filteredTypings := ti.filterTypings(request.ProjectID, request.Logger, newTypingNames) + if len(filteredTypings) != 0 { + return ti.installTypings(request.ProjectID, request.TypingsInfo, requestId, cachedTypingPaths, filteredTypings, request.Logger) + } + request.Logger.Log("ATA:: All typings are known to be missing or invalid - no need to install more typings") + } else { + request.Logger.Log("ATA:: No new typings were requested as a result of typings discovery") + } + + return cachedTypingPaths, nil + // !!! sheetal events to send + // this.event(response, "setTypings"); +} + +func (ti *TypingsInstaller) installTypings( + projectID tspath.Path, + typingsInfo *TypingsInfo, + requestID int32, + currentlyCachedTypings []string, + filteredTypings []string, + logger logging.Logger, +) ([]string, error) { + // !!! sheetal events to send + // send progress event + // this.sendResponse({ + // kind: EventBeginInstallTypes, + // eventId: requestId, + // typingsInstallerVersion: version, + // projectName: req.projectName, + // } as BeginInstallTypes); + + // const body: protocol.BeginInstallTypesEventBody = { + // eventId: response.eventId, + // packages: response.packagesToInstall, + // }; + // const eventName: protocol.BeginInstallTypesEventName = "beginInstallTypes"; + // this.event(body, eventName); + + scopedTypings := make([]string, len(filteredTypings)) + for i, packageName := range filteredTypings { + scopedTypings[i] = fmt.Sprintf("@types/%s@%s", packageName, tsVersionToUse) // @tscore.VersionMajorMinor) // This is normally @tsVersionMajorMinor but for now lets use latest + } + + if packageNames, ok := ti.installWorker(projectID, requestID, scopedTypings, logger); ok { + logger.Log(fmt.Sprintf("ATA:: Installed typings %v", packageNames)) + var installedTypingFiles []string + resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") + for _, packageName := range filteredTypings { + typingFile := ti.typingToFileName(resolver, packageName) + if typingFile == "" { + logger.Log(fmt.Sprintf("ATA:: Failed to find typing file for package '%s'", packageName)) + continue + } + + // packageName is guaranteed to exist in typesRegistry by filterTypings + distTags := ti.typesRegistry[packageName] + useVersion, ok := distTags["ts"+core.VersionMajorMinor()] + if !ok { + useVersion = distTags["latest"] + } + newVersion := semver.MustParse(useVersion) + newTyping := &CachedTyping{TypingsLocation: typingFile, Version: &newVersion} + ti.packageNameToTypingLocation.Store(packageName, newTyping) + installedTypingFiles = append(installedTypingFiles, typingFile) + } + logger.Log(fmt.Sprintf("ATA:: Installed typing files %v", installedTypingFiles)) + + return append(currentlyCachedTypings, installedTypingFiles...), nil + } + + // DO we really need these events + // this.event(response, "setTypings"); + logger.Log(fmt.Sprintf("ATA:: install request failed, marking packages as missing to prevent repeated requests: %v", filteredTypings)) + for _, typing := range filteredTypings { + ti.missingTypingsSet.Store(typing, true) + } + + return nil, errors.New("npm install failed") + + // !!! sheetal events to send + // const response: EndInstallTypes = { + // kind: EventEndInstallTypes, + // eventId: requestId, + // projectName: req.projectName, + // packagesToInstall: scopedTypings, + // installSuccess: ok, + // typingsInstallerVersion: version, + // }; + // this.sendResponse(response); + + // if (this.telemetryEnabled) { + // const body: protocol.TypingsInstalledTelemetryEventBody = { + // telemetryEventName: "typingsInstalled", + // payload: { + // installedPackages: response.packagesToInstall.join(","), + // installSuccess: response.installSuccess, + // typingsInstallerVersion: response.typingsInstallerVersion, + // }, + // }; + // const eventName: protocol.TelemetryEventName = "telemetry"; + // this.event(body, eventName); + // } + + // const body: protocol.EndInstallTypesEventBody = { + // eventId: response.eventId, + // packages: response.packagesToInstall, + // success: response.installSuccess, + // }; + // const eventName: protocol.EndInstallTypesEventName = "endInstallTypes"; + // this.event(body, eventName); +} + +func (ti *TypingsInstaller) installWorker( + projectID tspath.Path, + requestId int32, + packageNames []string, + logger logging.Logger, +) ([]string, bool) { + logger.Log(fmt.Sprintf("ATA:: #%d with cwd: %s arguments: %v", requestId, ti.typingsLocation, packageNames)) + ctx := context.Background() + err := installNpmPackages(ctx, packageNames, ti.concurrencySemaphore, func(packageNames []string) error { + var npmArgs []string + npmArgs = append(npmArgs, "install", "--ignore-scripts") + npmArgs = append(npmArgs, packageNames...) + npmArgs = append(npmArgs, "--save-dev", "--user-agent=\"typesInstaller/"+core.Version()+"\"") + output, err := ti.host.NpmInstall(ti.typingsLocation, npmArgs) + if err != nil { + logger.Log(fmt.Sprintf("ATA:: Output is: %s", output)) + return err + } + return nil + }) + logger.Log(fmt.Sprintf("TI:: npm install #%d completed", requestId)) + return packageNames, err == nil +} + +func installNpmPackages( + ctx context.Context, + packageNames []string, + concurrencySemaphore chan struct{}, + installPackages func(packages []string) error, +) error { + tg := core.NewThrottleGroup(ctx, concurrencySemaphore) + + currentCommandStart := 0 + currentCommandEnd := 0 + currentCommandSize := 100 + + for _, packageName := range packageNames { + currentCommandSize = currentCommandSize + len(packageName) + 1 + if currentCommandSize < 8000 { + currentCommandEnd++ + } else { + packages := packageNames[currentCommandStart:currentCommandEnd] + tg.Go(func() error { + return installPackages(packages) + }) + currentCommandStart = currentCommandEnd + currentCommandSize = 100 + len(packageName) + 1 + currentCommandEnd++ + } + } + + // Handle the final batch + if currentCommandStart < len(packageNames) { + packages := packageNames[currentCommandStart:currentCommandEnd] + tg.Go(func() error { + return installPackages(packages) + }) + } + + return tg.Wait() +} + +func (ti *TypingsInstaller) filterTypings( + projectID tspath.Path, + logger logging.Logger, + typingsToInstall []string, +) []string { + var result []string + for _, typing := range typingsToInstall { + typingKey := module.MangleScopedPackageName(typing) + if _, ok := ti.missingTypingsSet.Load(typingKey); ok { + logger.Log(fmt.Sprintf("ATA:: '%s':: '%s' is in missingTypingsSet - skipping...", typing, typingKey)) + continue + } + validationResult, name, isScopeName := ValidatePackageName(typing) + if validationResult != NameOk { + // add typing name to missing set so we won't process it again + ti.missingTypingsSet.Store(typingKey, true) + logger.Log("ATA:: " + renderPackageNameValidationFailure(typing, validationResult, name, isScopeName)) + continue + } + typesRegistryEntry, ok := ti.typesRegistry[typingKey] + if !ok { + logger.Log(fmt.Sprintf("ATA:: '%s':: Entry for package '%s' does not exist in local types registry - skipping...", typing, typingKey)) + continue + } + if typingLocation, ok := ti.packageNameToTypingLocation.Load(typingKey); ok && isTypingUpToDate(typingLocation, typesRegistryEntry) { + logger.Log(fmt.Sprintf("ATA:: '%s':: '%s' already has an up-to-date typing - skipping...", typing, typingKey)) + continue + } + result = append(result, typingKey) + } + return result +} + +func (ti *TypingsInstaller) init(projectID string, fs vfs.FS, logger logging.Logger) { + ti.initOnce.Do(func() { + logger.Log("ATA:: Global cache location '" + ti.typingsLocation + "'") //, safe file path '" + safeListPath + "', types map path '" + typesMapLocation + "`") + ti.processCacheLocation(projectID, fs, logger) + + // !!! sheetal handle npm path here if we would support it + // // If the NPM path contains spaces and isn't wrapped in quotes, do so. + // if (this.npmPath.includes(" ") && this.npmPath[0] !== `"`) { + // this.npmPath = `"${this.npmPath}"`; + // } + // if (this.log.isEnabled()) { + // this.log.writeLine(`Process id: ${process.pid}`); + // this.log.writeLine(`NPM location: ${this.npmPath} (explicit '${ts.server.Arguments.NpmLocation}' ${npmLocation === undefined ? "not " : ""} provided)`); + // this.log.writeLine(`validateDefaultNpmLocation: ${validateDefaultNpmLocation}`); + // } + + ti.ensureTypingsLocationExists(fs, logger) + logger.Log("ATA:: Updating types-registry@latest npm package...") + if _, err := ti.host.NpmInstall(ti.typingsLocation, []string{"install", "--ignore-scripts", "types-registry@latest"}); err == nil { + logger.Log("ATA:: Updated types-registry npm package") + } else { + logger.Log(fmt.Sprintf("ATA:: Error updating types-registry package: %v", err)) + // !!! sheetal events to send + // // store error info to report it later when it is known that server is already listening to events from typings installer + // this.delayedInitializationError = { + // kind: "event::initializationFailed", + // message: (e as Error).message, + // stack: (e as Error).stack, + // }; + + // const body: protocol.TypesInstallerInitializationFailedEventBody = { + // message: response.message, + // }; + // const eventName: protocol.TypesInstallerInitializationFailedEventName = "typesInstallerInitializationFailed"; + // this.event(body, eventName); + } + + ti.typesRegistry = ti.loadTypesRegistryFile(fs, logger) + }) +} + +type npmConfig struct { + DevDependencies map[string]any `json:"devDependencies"` +} + +type npmDependecyEntry struct { + Version string `json:"version"` +} +type npmLock struct { + Dependencies map[string]npmDependecyEntry `json:"dependencies"` + Packages map[string]npmDependecyEntry `json:"packages"` +} + +func (ti *TypingsInstaller) processCacheLocation(projectID string, fs vfs.FS, logger logging.Logger) { + logger.Log("ATA:: Processing cache location " + ti.typingsLocation) + packageJson := tspath.CombinePaths(ti.typingsLocation, "package.json") + packageLockJson := tspath.CombinePaths(ti.typingsLocation, "package-lock.json") + logger.Log("ATA:: Trying to find '" + packageJson + "'...") + if fs.FileExists(packageJson) && fs.FileExists((packageLockJson)) { + var npmConfig npmConfig + npmConfigContents := parseNpmConfigOrLock(fs, logger, packageJson, &npmConfig) + var npmLock npmLock + npmLockContents := parseNpmConfigOrLock(fs, logger, packageLockJson, &npmLock) + + logger.Log("ATA:: Loaded content of " + packageJson + ": " + npmConfigContents) + logger.Log("ATA:: Loaded content of " + packageLockJson + ": " + npmLockContents) + + // !!! sheetal strada uses Node10 + resolver := module.NewResolver(ti.host, &core.CompilerOptions{ModuleResolution: core.ModuleResolutionKindNodeNext}, "", "") + if npmConfig.DevDependencies != nil && (npmLock.Packages != nil || npmLock.Dependencies != nil) { + for key := range npmConfig.DevDependencies { + npmLockValue, npmLockValueExists := npmLock.Packages["node_modules/"+key] + if !npmLockValueExists { + npmLockValue, npmLockValueExists = npmLock.Dependencies[key] + } + if !npmLockValueExists { + // if package in package.json but not package-lock.json, skip adding to cache so it is reinstalled on next use + continue + } + // key is @types/ + packageName := tspath.GetBaseFileName(key) + if packageName == "" { + continue + } + typingFile := ti.typingToFileName(resolver, packageName) + if typingFile == "" { + continue + } + newVersion := semver.MustParse(npmLockValue.Version) + newTyping := &CachedTyping{TypingsLocation: typingFile, Version: &newVersion} + ti.packageNameToTypingLocation.Store(packageName, newTyping) + } + } + } + logger.Log("ATA:: Finished processing cache location " + ti.typingsLocation) +} + +func parseNpmConfigOrLock[T npmConfig | npmLock](fs vfs.FS, logger logging.Logger, location string, config *T) string { + contents, _ := fs.ReadFile(location) + _ = json.Unmarshal([]byte(contents), config) + return contents +} + +func (ti *TypingsInstaller) ensureTypingsLocationExists(fs vfs.FS, logger logging.Logger) { + npmConfigPath := tspath.CombinePaths(ti.typingsLocation, "package.json") + logger.Log("ATA:: Npm config file: " + npmConfigPath) + + if !fs.FileExists(npmConfigPath) { + logger.Log(fmt.Sprintf("ATA:: Npm config file: '%s' is missing, creating new one...", npmConfigPath)) + err := fs.WriteFile(npmConfigPath, "{ \"private\": true }", false) + if err != nil { + logger.Log(fmt.Sprintf("ATA:: Npm config file write failed: %v", err)) + } + } +} + +func (ti *TypingsInstaller) typingToFileName(resolver *module.Resolver, packageName string) string { + result := resolver.ResolveModuleName(packageName, tspath.CombinePaths(ti.typingsLocation, "index.d.ts"), core.ModuleKindNone, nil) + return result.ResolvedFileName +} + +func (ti *TypingsInstaller) loadTypesRegistryFile(fs vfs.FS, logger logging.Logger) map[string]map[string]string { + typesRegistryFile := tspath.CombinePaths(ti.typingsLocation, "node_modules/types-registry/index.json") + typesRegistryFileContents, ok := fs.ReadFile(typesRegistryFile) + if ok { + var entries map[string]map[string]map[string]string + err := json.Unmarshal([]byte(typesRegistryFileContents), &entries) + if err == nil { + if typesRegistry, ok := entries["entries"]; ok { + return typesRegistry + } + } + logger.Log(fmt.Sprintf("ATA:: Error when loading types registry file '%s': %v", typesRegistryFile, err)) + } else { + logger.Log(fmt.Sprintf("ATA:: Error reading types registry file '%s'", typesRegistryFile)) + } + return map[string]map[string]string{} +} diff --git a/internal/project/ata/ata_test.go b/internal/project/ata/ata_test.go new file mode 100644 index 0000000000..6eaf38b5ff --- /dev/null +++ b/internal/project/ata/ata_test.go @@ -0,0 +1,310 @@ +package ata_test + +import ( + "context" + "slices" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestATA(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + t.Run("local module should not be picked up", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/user/username/projects/project/app.js": `const c = require('./config');`, + "/user/username/projects/project/config.js": `export let x = 1`, + "/user/username/projects/project/jsconfig.json": `{ + "compilerOptions": { "moduleResolution": "commonjs" }, + "typeAcquisition": { "enable": true } + }`, + } + + testOptions := &projecttestutil.TestTypingsInstallerOptions{ + TypesRegistry: []string{"config"}, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, testOptions) + uri := lsproto.DocumentUri("file:///user/username/projects/project/app.js") + content := files["/user/username/projects/project/app.js"].(string) + + // Open the file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + ls, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + // Verify the local config.js file is included in the program + program := ls.GetProgram() + assert.Assert(t, program != nil) + configFile := program.GetSourceFile("/user/username/projects/project/config.js") + assert.Assert(t, configFile != nil, "local config.js should be included") + + // Verify that only types-registry was installed (no @types/config since it's a local module) + npmCalls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(npmCalls), 1) + assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + }) + + t.Run("configured projects", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true }, + }`, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + npmCalls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, len(npmCalls), 2) + assert.Equal(t, npmCalls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, npmCalls[0].Args[2], "types-registry@latest") + assert.Equal(t, npmCalls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Assert(t, slices.Contains(npmCalls[1].Args, "@types/jquery@latest")) + assert.Equal(t, len(utils.Client().RefreshDiagnosticsCalls()), 1) + }) + + t.Run("inferred projects", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/package.json": `{ + "name": "test", + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": `declare const $: { x: number }`, + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + + // Verify the types file was installed + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + assert.NilError(t, err) + program := ls.GetProgram() + jqueryTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") + }) + + t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/jquery.js": ``, + "/user/username/projects/project/tsconfig.json": `{ + "compilerOptions": { "allowJs": true }, + "typeAcquisition": { "enable": true, "disableFilenameBasedTypeAcquisition": true } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + TypesRegistry: []string{"jquery"}, + }) + + // Should only get types-registry install, no jquery install since filename-based acquisition is disabled + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/jquery.js"), 1, files["/user/username/projects/project/jquery.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + // Check that npm install was called once (only types-registry) + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 1, len(calls), "Expected exactly 1 npm install call") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + }) + + t.Run("discover from node_modules", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": "", + "/user/username/projects/project/package.json": `{ + "dependencies": { + "jquery": "1.0.0" + } + }`, + "/user/username/projects/project/jsconfig.json": `{}`, + "/user/username/projects/project/node_modules/commander/index.js": "", + "/user/username/projects/project/node_modules/commander/package.json": `{ "name": "commander" }`, + "/user/username/projects/project/node_modules/jquery/index.js": "", + "/user/username/projects/project/node_modules/jquery/package.json": `{ "name": "jquery" }`, + "/user/username/projects/project/node_modules/jquery/nested/package.json": `{ "name": "nested" }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + TypesRegistry: []string{"nested", "commander"}, + PackageToFile: map[string]string{ + "jquery": "declare const jquery: { x: number }", + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + }) + + t.Run("discover from bower_components", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/jsconfig.json": `{}`, + "/user/username/projects/project/bower_components/jquery/index.js": "", + "/user/username/projects/project/bower_components/jquery/bower.json": `{ "name": "jquery" }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": "declare const jquery: { x: number }", + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + + // Verify the types file was installed + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + assert.NilError(t, err) + jqueryTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") + }) + + t.Run("discover from bower.json", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ``, + "/user/username/projects/project/jsconfig.json": `{}`, + "/user/username/projects/project/bower.json": `{ + "dependencies": { + "jquery": "^3.1.0" + } + }`, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + PackageToFile: map[string]string{ + "jquery": "declare const jquery: { x: number }", + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[2], "@types/jquery@latest") + + // Verify the types file was installed + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + assert.NilError(t, err) + jqueryTypesFile := ls.GetProgram().GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts") + assert.Assert(t, jqueryTypesFile != nil, "jquery types should be installed") + }) + + t.Run("should install typings for unresolved imports", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/project/app.js": ` + import * as fs from "fs"; + import * as commander from "commander"; + import * as component from "@ember/component"; + `, + } + + session, utils := projecttestutil.SetupWithTypingsInstaller(files, &projecttestutil.TestTypingsInstallerOptions{ + PackageToFile: map[string]string{ + "node": "export let node: number", + "commander": "export let commander: number", + "ember__component": "export let ember__component: number", + }, + }) + + session.DidOpenFile(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js"), 1, files["/user/username/projects/project/app.js"].(string), lsproto.LanguageKindJavaScript) + session.WaitForBackgroundTasks() + + // Check that npm install was called twice + calls := utils.NpmExecutor().NpmInstallCalls() + assert.Equal(t, 2, len(calls), "Expected exactly 2 npm install calls") + assert.Equal(t, calls[0].Cwd, projecttestutil.TestTypingsLocation) + assert.DeepEqual(t, calls[0].Args, []string{"install", "--ignore-scripts", "types-registry@latest"}) + + // The second call should install all three packages at once + assert.Equal(t, calls[1].Cwd, projecttestutil.TestTypingsLocation) + assert.Equal(t, calls[1].Args[0], "install") + assert.Equal(t, calls[1].Args[1], "--ignore-scripts") + // Check that all three packages are in the install command + installArgs := calls[1].Args + assert.Assert(t, slices.Contains(installArgs, "@types/ember__component@latest")) + assert.Assert(t, slices.Contains(installArgs, "@types/commander@latest")) + assert.Assert(t, slices.Contains(installArgs, "@types/node@latest")) + + // Verify the types files were installed + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///user/username/projects/project/app.js")) + assert.NilError(t, err) + program := ls.GetProgram() + nodeTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/node/index.d.ts") + assert.Assert(t, nodeTypesFile != nil, "node types should be installed") + commanderTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts") + assert.Assert(t, commanderTypesFile != nil, "commander types should be installed") + emberComponentTypesFile := program.GetSourceFile(projecttestutil.TestTypingsLocation + "/node_modules/@types/ember__component/index.d.ts") + assert.Assert(t, emberComponentTypesFile != nil, "ember__component types should be installed") + }) +} diff --git a/internal/project/discovertypings.go b/internal/project/ata/discovertypings.go similarity index 81% rename from internal/project/discovertypings.go rename to internal/project/ata/discovertypings.go index f36400f13d..8a87b3ea92 100644 --- a/internal/project/discovertypings.go +++ b/internal/project/ata/discovertypings.go @@ -1,4 +1,4 @@ -package project +package ata import ( "fmt" @@ -10,28 +10,24 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/packagejson" + "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/semver" "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" ) -type CachedTyping struct { - TypingsLocation string - Version semver.Version -} - -func IsTypingUpToDate(cachedTyping *CachedTyping, availableTypingVersions map[string]string) bool { +func isTypingUpToDate(cachedTyping *CachedTyping, availableTypingVersions map[string]string) bool { useVersion, ok := availableTypingVersions["ts"+core.VersionMajorMinor()] if !ok { useVersion = availableTypingVersions["latest"] } availableVersion := semver.MustParse(useVersion) - return availableVersion.Compare(&cachedTyping.Version) <= 0 + return availableVersion.Compare(cachedTyping.Version) <= 0 } func DiscoverTypings( fs vfs.FS, - log func(s string), + logger logging.Logger, typingsInfo *TypingsInfo, fileNames []string, projectRootPath string, @@ -47,7 +43,7 @@ func DiscoverTypings( }) if typingsInfo.TypeAcquisition.Include != nil { - addInferredTypings(fs, log, inferredTypings, typingsInfo.TypeAcquisition.Include, "Explicitly included types") + addInferredTypings(fs, logger, inferredTypings, typingsInfo.TypeAcquisition.Include, "Explicitly included types") } exclude := typingsInfo.TypeAcquisition.Exclude @@ -59,31 +55,37 @@ func DiscoverTypings( } possibleSearchDirs[projectRootPath] = true for searchDir := range possibleSearchDirs { - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, log, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components") - filesToWatch = addTypingNamesAndGetFilesToWatch(fs, log, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules") + filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "bower.json", "bower_components") + filesToWatch = addTypingNamesAndGetFilesToWatch(fs, logger, inferredTypings, filesToWatch, searchDir, "package.json", "node_modules") } } if !typingsInfo.TypeAcquisition.DisableFilenameBasedTypeAcquisition.IsTrue() { - getTypingNamesFromSourceFileNames(fs, log, inferredTypings, fileNames) + getTypingNamesFromSourceFileNames(fs, logger, inferredTypings, fileNames) } // add typings for unresolved imports - modules := core.Map(typingsInfo.UnresolvedImports, core.NonRelativeModuleNameForTypingCache) - slices.Sort(modules) - modules = slices.Compact(modules) - addInferredTypings(fs, log, inferredTypings, modules, "Inferred typings from unresolved imports") + var modules []string + if typingsInfo.UnresolvedImports != nil { + modules = make([]string, 0, typingsInfo.UnresolvedImports.Len()) + for module := range typingsInfo.UnresolvedImports.Keys() { + modules = append(modules, core.NonRelativeModuleNameForTypingCache(module)) + } + slices.Sort(modules) + modules = slices.Compact(modules) + } + addInferredTypings(fs, logger, inferredTypings, modules, "Inferred typings from unresolved imports") // Remove typings that the user has added to the exclude list for _, excludeTypingName := range exclude { delete(inferredTypings, excludeTypingName) - log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) + logger.Log(fmt.Sprintf("ATA:: Typing for %s is in exclude list, will be ignored.", excludeTypingName)) } // Add the cached typing locations for inferred typings that are already installed packageNameToTypingLocation.Range(func(name string, typing *CachedTyping) bool { registryEntry := typesRegistry[name] - if inferredTypings[name] == "" && registryEntry != nil && IsTypingUpToDate(typing, registryEntry) { + if inferredTypings[name] == "" && registryEntry != nil && isTypingUpToDate(typing, registryEntry) { inferredTypings[name] = typing.TypingsLocation } return true @@ -96,7 +98,7 @@ func DiscoverTypings( newTypingNames = append(newTypingNames, typing) } } - log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch)) + logger.Log(fmt.Sprintf("ATA:: Finished typings discovery: cachedTypingsPaths: %v newTypingNames: %v, filesToWatch %v", cachedTypingPaths, newTypingNames, filesToWatch)) return cachedTypingPaths, newTypingNames, filesToWatch } @@ -108,11 +110,11 @@ func addInferredTyping(inferredTypings map[string]string, typingName string) { func addInferredTypings( fs vfs.FS, - log func(s string), + logger logging.Logger, inferredTypings map[string]string, typingNames []string, message string, ) { - log(fmt.Sprintf("ATA:: %s: %v", message, typingNames)) + logger.Log(fmt.Sprintf("ATA:: %s: %v", message, typingNames)) for _, typingName := range typingNames { addInferredTyping(inferredTypings, typingName) } @@ -126,7 +128,7 @@ func addInferredTypings( */ func getTypingNamesFromSourceFileNames( fs vfs.FS, - log func(s string), + logger logging.Logger, inferredTypings map[string]string, fileNames []string, ) { @@ -141,10 +143,10 @@ func getTypingNamesFromSourceFileNames( } } if len(fromFileNames) > 0 { - addInferredTypings(fs, log, inferredTypings, fromFileNames, "Inferred typings from file names") + addInferredTypings(fs, logger, inferredTypings, fromFileNames, "Inferred typings from file names") } if hasJsxFile { - log("ATA:: Inferred 'react' typings due to presence of '.jsx' extension") + logger.Log("ATA:: Inferred 'react' typings due to presence of '.jsx' extension") addInferredTyping(inferredTypings, "react") } } @@ -159,7 +161,7 @@ func getTypingNamesFromSourceFileNames( */ func addTypingNamesAndGetFilesToWatch( fs vfs.FS, - log func(s string), + logger logging.Logger, inferredTypings map[string]string, filesToWatch []string, projectRootPath string, @@ -182,7 +184,7 @@ func addTypingNamesAndGetFilesToWatch( manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.DevDependencies.Value)) manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.OptionalDependencies.Value)) manifestTypingNames = slices.AppendSeq(manifestTypingNames, maps.Keys(manifest.PeerDependencies.Value)) - addInferredTypings(fs, log, inferredTypings, manifestTypingNames, "Typing names in '"+manifestPath+"' dependencies") + addInferredTypings(fs, logger, inferredTypings, manifestTypingNames, "Typing names in '"+manifestPath+"' dependencies") } } @@ -244,7 +246,7 @@ func addTypingNamesAndGetFilesToWatch( } - log(fmt.Sprintf("ATA:: Searching for typing names in %s; all files: %v", packagesFolderPath, dependencyManifestNames)) + logger.Log(fmt.Sprintf("ATA:: Searching for typing names in %s; all files: %v", packagesFolderPath, dependencyManifestNames)) // Once we have the names of things to look up, we iterate over // and either collect their included typings, or add them to the @@ -267,16 +269,16 @@ func addTypingNamesAndGetFilesToWatch( if len(ownTypes) != 0 { absolutePath := tspath.GetNormalizedAbsolutePath(ownTypes, tspath.GetDirectoryPath(manifestPath)) if fs.FileExists(absolutePath) { - log(fmt.Sprintf("ATA:: Package '%s' provides its own types.", manifest.Name.Value)) + logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types.", manifest.Name.Value)) inferredTypings[manifest.Name.Value] = absolutePath } else { - log(fmt.Sprintf("ATA:: Package '%s' provides its own types but they are missing.", manifest.Name.Value)) + logger.Log(fmt.Sprintf("ATA:: Package '%s' provides its own types but they are missing.", manifest.Name.Value)) } } else { packageNames = append(packageNames, manifest.Name.Value) } } - addInferredTypings(fs, log, inferredTypings, packageNames, " Found package names") + addInferredTypings(fs, logger, inferredTypings, packageNames, " Found package names") return filesToWatch } diff --git a/internal/project/discovertypings_test.go b/internal/project/ata/discovertypings_test.go similarity index 70% rename from internal/project/discovertypings_test.go rename to internal/project/ata/discovertypings_test.go index 4a09bbd7c8..47d1dd18b4 100644 --- a/internal/project/discovertypings_test.go +++ b/internal/project/ata/discovertypings_test.go @@ -1,4 +1,4 @@ -package project_test +package ata_test import ( "maps" @@ -6,7 +6,8 @@ import ( "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/project/ata" + "github.com/microsoft/typescript-go/internal/project/logging" "github.com/microsoft/typescript-go/internal/semver" "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" "github.com/microsoft/typescript-go/internal/vfs/vfstest" @@ -17,26 +18,23 @@ func TestDiscoverTypings(t *testing.T) { t.Parallel() t.Run("should use mappings from safe list", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", "/home/src/projects/project/jquery.js": "", "/home/src/projects/project/chroma.min.js": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ - CompilerOptions: &core.CompilerOptions{}, - TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{}, + logger, + &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, }, []string{"/home/src/projects/project/app.js", "/home/src/projects/project/jquery.js", "/home/src/projects/project/chroma.min.js"}, "/home/src/projects/project", - &collections.SyncMap[string, *project.CachedTyping]{}, + &collections.SyncMap[string, *ata.CachedTyping]{}, map[string]map[string]string{}, ) assert.Assert(t, cachedTypingPaths == nil) @@ -52,24 +50,23 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should return node for core modules", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("assert", "somename") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"assert", "somename"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", - &collections.SyncMap[string, *project.CachedTyping]{}, + &collections.SyncMap[string, *ata.CachedTyping]{}, map[string]map[string]string{}, ) assert.Assert(t, cachedTypingPaths == nil) @@ -85,26 +82,26 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should use cached locations", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", "/home/src/projects/project/node.d.ts": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cache := collections.SyncMap[string, *project.CachedTyping]{} - cache.Store("node", &project.CachedTyping{ + cache := collections.SyncMap[string, *ata.CachedTyping]{} + version := semver.MustParse("1.3.0") + cache.Store("node", &ata.CachedTyping{ TypingsLocation: "/home/src/projects/project/node.d.ts", - Version: semver.MustParse("1.3.0"), + Version: &version, }) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("fs", "bar") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"fs", "bar"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", @@ -127,26 +124,26 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should gracefully handle packages that have been removed from the types-registry", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", "/home/src/projects/project/node.d.ts": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cache := collections.SyncMap[string, *project.CachedTyping]{} - cache.Store("node", &project.CachedTyping{ + cache := collections.SyncMap[string, *ata.CachedTyping]{} + version := semver.MustParse("1.3.0") + cache.Store("node", &ata.CachedTyping{ TypingsLocation: "/home/src/projects/project/node.d.ts", - Version: semver.MustParse("1.3.0"), + Version: &version, }) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("fs", "bar") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"fs", "bar"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", @@ -166,26 +163,23 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should search only 2 levels deep", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", "/home/src/projects/project/node_modules/a/package.json": `{ "name": "a" }`, "/home/src/projects/project/node_modules/a/b/package.json": `{ "name": "b" }`, } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ - CompilerOptions: &core.CompilerOptions{}, - TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{}, + logger, + &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", - &collections.SyncMap[string, *project.CachedTyping]{}, + &collections.SyncMap[string, *ata.CachedTyping]{}, map[string]map[string]string{}, ) assert.Assert(t, cachedTypingPaths == nil) @@ -200,25 +194,22 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should support scoped packages", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", "/home/src/projects/project/node_modules/@a/b/package.json": `{ "name": "@a/b" }`, } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ - CompilerOptions: &core.CompilerOptions{}, - TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{}, + logger, + &ata.TypingsInfo{ + CompilerOptions: &core.CompilerOptions{}, + TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", - &collections.SyncMap[string, *project.CachedTyping]{}, + &collections.SyncMap[string, *ata.CachedTyping]{}, map[string]map[string]string{}, ) assert.Assert(t, cachedTypingPaths == nil) @@ -233,29 +224,30 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should install expired typings", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cache := collections.SyncMap[string, *project.CachedTyping]{} - cache.Store("node", &project.CachedTyping{ + cache := collections.SyncMap[string, *ata.CachedTyping]{} + nodeVersion := semver.MustParse("1.3.0") + commanderVersion := semver.MustParse("1.0.0") + cache.Store("node", &ata.CachedTyping{ TypingsLocation: projecttestutil.TestTypingsLocation + "/node_modules/@types/node/index.d.ts", - Version: semver.MustParse("1.3.0"), + Version: &nodeVersion, }) - cache.Store("commander", &project.CachedTyping{ + cache.Store("commander", &ata.CachedTyping{ TypingsLocation: projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts", - Version: semver.MustParse("1.0.0"), + Version: &commanderVersion, }) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("http", "commander") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"http", "commander"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", @@ -279,28 +271,28 @@ func TestDiscoverTypings(t *testing.T) { t.Run("should install expired typings with prerelease version of tsserver", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cache := collections.SyncMap[string, *project.CachedTyping]{} - cache.Store("node", &project.CachedTyping{ + cache := collections.SyncMap[string, *ata.CachedTyping]{} + nodeVersion := semver.MustParse("1.0.0") + cache.Store("node", &ata.CachedTyping{ TypingsLocation: projecttestutil.TestTypingsLocation + "/node_modules/@types/node/index.d.ts", - Version: semver.MustParse("1.0.0"), + Version: &nodeVersion, }) config := maps.Clone(projecttestutil.TypesRegistryConfig()) delete(config, "ts"+core.VersionMajorMinor()) - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("http") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"http"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", @@ -321,31 +313,32 @@ func TestDiscoverTypings(t *testing.T) { t.Run("prerelease typings are properly handled", func(t *testing.T) { t.Parallel() - var output []string + logger := logging.NewLogTree("DiscoverTypings") files := map[string]string{ "/home/src/projects/project/app.js": "", } fs := vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/) - cache := collections.SyncMap[string, *project.CachedTyping]{} - cache.Store("node", &project.CachedTyping{ + cache := collections.SyncMap[string, *ata.CachedTyping]{} + nodeVersion := semver.MustParse("1.3.0-next.0") + commanderVersion := semver.MustParse("1.3.0-next.0") + cache.Store("node", &ata.CachedTyping{ TypingsLocation: projecttestutil.TestTypingsLocation + "/node_modules/@types/node/index.d.ts", - Version: semver.MustParse("1.3.0-next.0"), + Version: &nodeVersion, }) - cache.Store("commander", &project.CachedTyping{ + cache.Store("commander", &ata.CachedTyping{ TypingsLocation: projecttestutil.TestTypingsLocation + "/node_modules/@types/commander/index.d.ts", - Version: semver.MustParse("1.3.0-next.0"), + Version: &commanderVersion, }) config := maps.Clone(projecttestutil.TypesRegistryConfig()) config["ts"+core.VersionMajorMinor()] = "1.3.0-next.1" - cachedTypingPaths, newTypingNames, filesToWatch := project.DiscoverTypings( + unresolvedImports := collections.NewSetFromItems("http", "commander") + cachedTypingPaths, newTypingNames, filesToWatch := ata.DiscoverTypings( fs, - func(s string) { - output = append(output, s) - }, - &project.TypingsInfo{ + logger, + &ata.TypingsInfo{ CompilerOptions: &core.CompilerOptions{}, TypeAcquisition: &core.TypeAcquisition{Enable: core.TSTrue}, - UnresolvedImports: []string{"http", "commander"}, + UnresolvedImports: unresolvedImports, }, []string{"/home/src/projects/project/app.js"}, "/home/src/projects/project", diff --git a/internal/project/installnpmpackages_test.go b/internal/project/ata/installnpmpackages_test.go similarity index 97% rename from internal/project/installnpmpackages_test.go rename to internal/project/ata/installnpmpackages_test.go index 2f7801fae5..8f98b96156 100644 --- a/internal/project/installnpmpackages_test.go +++ b/internal/project/ata/installnpmpackages_test.go @@ -1,10 +1,10 @@ -package project_test +package ata import ( + "fmt" "sync/atomic" "testing" - "github.com/microsoft/typescript-go/internal/project" "gotest.tools/v3/assert" ) @@ -500,21 +500,24 @@ func TestInstallNpmPackages(t *testing.T) { t.Run("works when the command is too long to install all packages at once", func(t *testing.T) { t.Parallel() var calledCount atomic.Int32 - hasError := project.InstallNpmPackages(packageNames, func(packages []string, hasError *atomic.Bool) { + sema := make(chan struct{}, 5) + err := installNpmPackages(t.Context(), packageNames, sema, func(packages []string) error { calledCount.Add(1) + return nil }) - assert.Equal(t, hasError, false) + assert.NilError(t, err) assert.Equal(t, int(calledCount.Load()), 2) }) t.Run("installs remaining packages when one of the partial command fails", func(t *testing.T) { t.Parallel() var calledCount atomic.Int32 - hasError := project.InstallNpmPackages(packageNames, func(packages []string, hasError *atomic.Bool) { + sema := make(chan struct{}, 5) + err := installNpmPackages(t.Context(), packageNames, sema, func(packages []string) error { calledCount.Add(1) - hasError.Store(true) + return fmt.Errorf("failed to install packages: %v", packages) }) - assert.Equal(t, hasError, true) + assert.ErrorContains(t, err, "failed to install packages") assert.Equal(t, int(calledCount.Load()), 2) }) } diff --git a/internal/project/typesmap.go b/internal/project/ata/typesmap.go similarity index 99% rename from internal/project/typesmap.go rename to internal/project/ata/typesmap.go index 5ea1389ea5..2652db48e4 100644 --- a/internal/project/typesmap.go +++ b/internal/project/ata/typesmap.go @@ -1,4 +1,4 @@ -package project +package ata // type safeListEntry struct { // match string diff --git a/internal/project/validatepackagename.go b/internal/project/ata/validatepackagename.go similarity index 93% rename from internal/project/validatepackagename.go rename to internal/project/ata/validatepackagename.go index 7b56da4b5a..ac6dea490b 100644 --- a/internal/project/validatepackagename.go +++ b/internal/project/ata/validatepackagename.go @@ -1,4 +1,4 @@ -package project +package ata import ( "fmt" @@ -49,7 +49,7 @@ func validatePackageNameWorker(packageName string, supportScopedPackage bool) (r if supportScopedPackage { if withoutScope, found := strings.CutPrefix(packageName, "@"); found { scope, scopedPackageName, found := strings.Cut(withoutScope, "/") - if found && len(scope) > 0 && len(scopedPackageName) > 0 && strings.Index(scopedPackageName, "/") == -1 { + if found && len(scope) > 0 && len(scopedPackageName) > 0 && !strings.Contains(scopedPackageName, "/") { scopeResult, _, _ := validatePackageNameWorker(scope /*supportScopedPackage*/, false) if scopeResult != NameOk { return scopeResult, scope, true @@ -69,7 +69,7 @@ func validatePackageNameWorker(packageName string, supportScopedPackage bool) (r } /** @internal */ -func RenderPackageNameValidationFailure(typing string, result NameValidationResult, name string, isScopeName bool) string { +func renderPackageNameValidationFailure(typing string, result NameValidationResult, name string, isScopeName bool) string { var kind string if isScopeName { kind = "Scope" diff --git a/internal/project/ata/validatepackagename_test.go b/internal/project/ata/validatepackagename_test.go new file mode 100644 index 0000000000..9a12206dd6 --- /dev/null +++ b/internal/project/ata/validatepackagename_test.go @@ -0,0 +1,107 @@ +package ata_test + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/project/ata" + "gotest.tools/v3/assert" +) + +func TestValidatePackageName(t *testing.T) { + t.Parallel() + t.Run("name cannot be too long", func(t *testing.T) { + t.Parallel() + packageName := "a" + for range 8 { + packageName += packageName + } + status, _, _ := ata.ValidatePackageName(packageName) + assert.Equal(t, status, ata.NameTooLong) + }) + t.Run("package name cannot start with dot", func(t *testing.T) { + t.Parallel() + status, _, _ := ata.ValidatePackageName(".foo") + assert.Equal(t, status, ata.NameStartsWithDot) + }) + t.Run("package name cannot start with underscore", func(t *testing.T) { + t.Parallel() + status, _, _ := ata.ValidatePackageName("_foo") + assert.Equal(t, status, ata.NameStartsWithUnderscore) + }) + t.Run("package non URI safe characters are not supported", func(t *testing.T) { + t.Parallel() + status, _, _ := ata.ValidatePackageName(" scope ") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + status, _, _ = ata.ValidatePackageName("; say ‘Hello from TypeScript!’ #") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + status, _, _ = ata.ValidatePackageName("a/b/c") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + }) + t.Run("scoped package name is supported", func(t *testing.T) { + t.Parallel() + status, _, _ := ata.ValidatePackageName("@scope/bar") + assert.Equal(t, status, ata.NameOk) + }) + t.Run("scoped name in scoped package name cannot start with dot", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@.scope/bar") + assert.Equal(t, status, ata.NameStartsWithDot) + assert.Equal(t, name, ".scope") + assert.Equal(t, isScopeName, true) + status, name, isScopeName = ata.ValidatePackageName("@.scope/.bar") + assert.Equal(t, status, ata.NameStartsWithDot) + assert.Equal(t, name, ".scope") + assert.Equal(t, isScopeName, true) + }) + t.Run("scoped name in scoped package name cannot start with dot", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@_scope/bar") + assert.Equal(t, status, ata.NameStartsWithUnderscore) + assert.Equal(t, name, "_scope") + assert.Equal(t, isScopeName, true) + status, name, isScopeName = ata.ValidatePackageName("@_scope/_bar") + assert.Equal(t, status, ata.NameStartsWithUnderscore) + assert.Equal(t, name, "_scope") + assert.Equal(t, isScopeName, true) + }) + t.Run("scope name in scoped package name with non URI safe characters are not supported", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@ scope /bar") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + assert.Equal(t, name, " scope ") + assert.Equal(t, isScopeName, true) + status, name, isScopeName = ata.ValidatePackageName("@; say ‘Hello from TypeScript!’ #/bar") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + assert.Equal(t, name, "; say ‘Hello from TypeScript!’ #") + assert.Equal(t, isScopeName, true) + status, name, isScopeName = ata.ValidatePackageName("@ scope / bar ") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + assert.Equal(t, name, " scope ") + assert.Equal(t, isScopeName, true) + }) + t.Run("package name in scoped package name cannot start with dot", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@scope/.bar") + assert.Equal(t, status, ata.NameStartsWithDot) + assert.Equal(t, name, ".bar") + assert.Equal(t, isScopeName, false) + }) + t.Run("package name in scoped package name cannot start with underscore", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@scope/_bar") + assert.Equal(t, status, ata.NameStartsWithUnderscore) + assert.Equal(t, name, "_bar") + assert.Equal(t, isScopeName, false) + }) + t.Run("package name in scoped package name with non URI safe characters are not supported", func(t *testing.T) { + t.Parallel() + status, name, isScopeName := ata.ValidatePackageName("@scope/ bar ") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + assert.Equal(t, name, " bar ") + assert.Equal(t, isScopeName, false) + status, name, isScopeName = ata.ValidatePackageName("@scope/; say ‘Hello from TypeScript!’ #") + assert.Equal(t, status, ata.NameContainsNonURISafeCharacters) + assert.Equal(t, name, "; say ‘Hello from TypeScript!’ #") + assert.Equal(t, isScopeName, false) + }) +} diff --git a/internal/project/ata_test.go b/internal/project/ata_test.go deleted file mode 100644 index e7c392faa9..0000000000 --- a/internal/project/ata_test.go +++ /dev/null @@ -1,798 +0,0 @@ -package project_test - -import ( - "slices" - "testing" - "time" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/project" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -func TestAta(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - t.Run("local module should not be picked up", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": `const c = require('./config');`, - "/user/username/projects/project/config.js": `export let x = 1`, - "/user/username/projects/project/jsconfig.json": `{ - "compilerOptions": { "moduleResolution": "commonjs" }, - "typeAcquisition": { "enable": true }, - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"config"}, - }, - }) - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - assert.Equal(t, p.Kind(), project.KindConfigured) - program := p.CurrentProgram() - assert.Assert(t, program.GetSourceFile("/user/username/projects/project/config.js") != nil) - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - t.Run("configured projects", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/tsconfig.json": `{ - "compilerOptions": { "allowJs": true }, - "typeAcquisition": { "enable": true }, - }`, - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": `declare const $: { x: number }`, - }, - }, - }) - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - assert.Equal(t, p.Kind(), project.KindConfigured) - success := <-host.ServiceOptions.InstallStatus - assert.Equal(t, success, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 2, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - t.Run("inferred projects", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": `declare const $: { x: number }`, - }, - }, - }) - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - assert.Equal(t, p.Kind(), project.KindInferred) - success := <-host.ServiceOptions.InstallStatus - assert.Equal(t, success, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 2, - Project: p, - Status: "Skipped 1 typings", - }) - }) - - t.Run("type acquisition with disableFilenameBasedTypeAcquisition:true", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/jquery.js": ``, - "/user/username/projects/project/tsconfig.json": `{ - "compilerOptions": { "allowJs": true }, - "typeAcquisition": { "enable": true, "disableFilenameBasedTypeAcquisition": true }, - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"jquery"}, - }, - }) - service.OpenFile("/user/username/projects/project/jquery.js", files["/user/username/projects/project/jquery.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/jquery.js") - assert.Equal(t, p.Kind(), project.KindConfigured) - - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - t.Run("deduplicate from local @types packages", func(t *testing.T) { - t.Skip("Todo - implement removing local @types from include list") - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/node_modules/@types/node/index.d.ts": "declare var node;", - "/user/username/projects/project/jsconfig.json": `{ - "typeAcquisition": { "include": ["node"] }, - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"node"}, - }, - }) - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - assert.Equal(t, p.Kind(), project.KindConfigured) - - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - t.Run("Throttle - scheduled run install requests without reaching limit", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project1/app.js": "", - "/user/username/projects/project1/file3.d.ts": "", - "/user/username/projects/project1/jsconfig.json": `{ - "typeAcquisition": { "include": ["jquery", "cordova", "lodash"] }, - }`, - "/user/username/projects/project2/app.js": "", - "/user/username/projects/project2/file3.d.ts": "", - "/user/username/projects/project2/jsconfig.json": `{ - "typeAcquisition": { "include": ["grunt", "gulp", "commander"] }, - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "commander": "declare const commander: { x: number }", - "jquery": "declare const jquery: { x: number }", - "lodash": "declare const lodash: { x: number }", - "cordova": "declare const cordova: { x: number }", - "grunt": "declare const grunt: { x: number }", - "gulp": "declare const grunt: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project1/app.js", files["/user/username/projects/project1/app.js"], core.ScriptKindJS, "") - service.OpenFile("/user/username/projects/project2/app.js", files["/user/username/projects/project2/app.js"], core.ScriptKindJS, "") - _, p1 := service.EnsureDefaultProjectForFile("/user/username/projects/project1/app.js") - _, p2 := service.EnsureDefaultProjectForFile("/user/username/projects/project2/app.js") - var installStatuses []project.TypingsInstallerStatus - installStatuses = append(installStatuses, <-host.ServiceOptions.InstallStatus, <-host.ServiceOptions.InstallStatus) - // Order can be non deterministic since they both will run in parallel - not looking into request ID - assert.Assert(t, slices.ContainsFunc(installStatuses, func(s project.TypingsInstallerStatus) bool { - return s.Project == p1 && s.Status == "Success" - })) - assert.Assert(t, slices.ContainsFunc(installStatuses, func(s project.TypingsInstallerStatus) bool { - return s.Project == p2 && s.Status == "Success" - })) - }) - - t.Run("Throttle - scheduled run install requests reaching limit", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project1/app.js": "", - "/user/username/projects/project1/file3.d.ts": "", - "/user/username/projects/project1/jsconfig.json": `{ - "typeAcquisition": { "include": ["jquery", "cordova", "lodash"] }, - }`, - "/user/username/projects/project2/app.js": "", - "/user/username/projects/project2/file3.d.ts": "", - "/user/username/projects/project2/jsconfig.json": `{ - "typeAcquisition": { "include": ["grunt", "gulp", "commander"] }, - }`, - } - expectedP1First := true - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "commander": "declare const commander: { x: number }", - "jquery": "declare const jquery: { x: number }", - "lodash": "declare const lodash: { x: number }", - "cordova": "declare const cordova: { x: number }", - "grunt": "declare const grunt: { x: number }", - "gulp": "declare const gulp: { x: number }", - }, - }, - TypingsInstallerOptions: project.TypingsInstallerOptions{ - ThrottleLimit: 1, - }, - }) - - host.TestOptions.CheckBeforeNpmInstall = func(cwd string, npmInstallArgs []string) { - for { - pendingCount := service.TypingsInstaller().PendingRunRequestsCount() - if pendingCount == 1 { - if slices.Contains(npmInstallArgs, "@types/gulp@latest") { - expectedP1First = false - } - host.TestOptions.CheckBeforeNpmInstall = nil // Stop checking after first run - break - } - assert.NilError(t, t.Context().Err()) - time.Sleep(10 * time.Millisecond) - } - } - - service.OpenFile("/user/username/projects/project1/app.js", files["/user/username/projects/project1/app.js"], core.ScriptKindJS, "") - service.OpenFile("/user/username/projects/project2/app.js", files["/user/username/projects/project2/app.js"], core.ScriptKindJS, "") - _, p1 := service.EnsureDefaultProjectForFile("/user/username/projects/project1/app.js") - _, p2 := service.EnsureDefaultProjectForFile("/user/username/projects/project2/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status.Project, core.IfElse(expectedP1First, p1, p2)) - assert.Equal(t, status.Status, "Success") - status = <-host.ServiceOptions.InstallStatus - assert.Equal(t, status.Project, core.IfElse(expectedP1First, p2, p1)) - assert.Equal(t, status.Status, "Success") - }) - - t.Run("discover from node_modules", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "dependencies": { - "jquery": "1.0.0" - } - }`, - "/user/username/projects/project/jsconfig.json": `{}`, - "/user/username/projects/project/node_modules/commander/index.js": "", - "/user/username/projects/project/node_modules/commander/package.json": `{ "name": "commander" }`, - "/user/username/projects/project/node_modules/jquery/index.js": "", - "/user/username/projects/project/node_modules/jquery/package.json": `{ "name": "jquery" }`, - "/user/username/projects/project/node_modules/jquery/nested/package.json": `{ "name": "nested" }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"nested", "commander"}, - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - }) - - // Explicit types prevent automatic inclusion from package.json listing - t.Run("discover from node_modules empty types", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "dependencies": { - "jquery": "1.0.0" - } - }`, - "/user/username/projects/project/jsconfig.json": `{ - "compilerOptions": { - "types": [] - } - }`, - "/user/username/projects/project/node_modules/commander/index.js": "", - "/user/username/projects/project/node_modules/commander/package.json": `{ "name": "commander" }`, - "/user/username/projects/project/node_modules/jquery/index.js": "", - "/user/username/projects/project/node_modules/jquery/package.json": `{ "name": "jquery" }`, - "/user/username/projects/project/node_modules/jquery/nested/package.json": `{ "name": "nested" }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"nested", "commander"}, - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - // A type reference directive will not resolve to the global typings cache - t.Run("discover from node_modules explicit types", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "dependencies": { - "jquery": "1.0.0" - } - }`, - "/user/username/projects/project/jsconfig.json": `{ - "compilerOptions": { - "types": ["jquery"] - } - }`, - "/user/username/projects/project/node_modules/commander/index.js": "", - "/user/username/projects/project/node_modules/commander/package.json": `{ "name": "commander" }`, - "/user/username/projects/project/node_modules/jquery/index.js": "", - "/user/username/projects/project/node_modules/jquery/package.json": `{ "name": "jquery" }`, - "/user/username/projects/project/node_modules/jquery/nested/package.json": `{ "name": "nested" }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"nested", "commander"}, - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 0 typings", - }) - }) - - // However, explicit types will not prevent unresolved imports from pulling in typings - t.Run("discover from node_modules empty types has import", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": `import "jquery";`, - "/user/username/projects/project/package.json": `{ - "dependencies": { - "jquery": "1.0.0" - } - }`, - "/user/username/projects/project/jsconfig.json": `{ - "compilerOptions": { - "types": [] - } - }`, - "/user/username/projects/project/node_modules/commander/index.js": "", - "/user/username/projects/project/node_modules/commander/package.json": `{ "name": "commander" }`, - "/user/username/projects/project/node_modules/jquery/index.js": "", - "/user/username/projects/project/node_modules/jquery/package.json": `{ "name": "jquery" }`, - "/user/username/projects/project/node_modules/jquery/nested/package.json": `{ "name": "nested" }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"nested", "commander"}, - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - }) - - t.Run("discover from bower_components", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/jsconfig.json": `{}`, - "/user/username/projects/project/bower_components/jquery/index.js": "", - "/user/username/projects/project/bower_components/jquery/bower.json": `{ "name": "jquery" }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) - }) - - t.Run("discover from bower.json", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/jsconfig.json": `{}`, - "/user/username/projects/project/bower.json": `{ - "dependencies": { - "jquery": "^3.1.0" - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": "declare const jquery: { x: number }", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts") != nil) - }) - - t.Run("Malformed package.json should be watched", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ``, - "/user/username/projects/project/package.json": `{ "dependencies": { "co } }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "commander": "export let x: number", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 1 typings", - }) - assert.NilError(t, host.FS().WriteFile( - "/user/username/projects/project/package.json", - `{ "dependencies": { "commander": "0.0.2" } }`, - false, - )) - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeChanged, - Uri: "file:///user/username/projects/project/package.json", - }, - })) - status = <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 2, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) - }) - - t.Run("should install typings for unresolved imports", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ` - import * as fs from "fs"; - import * as commander from "commander"; - import * as component from "@ember/component"; - `, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "node": "export let node: number", - "commander": "export let commander: number", - "ember__component": "export let ember__component: number", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/node/index.d.ts") != nil) - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/ember__component/index.d.ts") != nil) - }) - - t.Run("should redo resolution that resolved to '.js' file after typings are installed", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": ` - import * as commander from "commander"; - `, - "/user/username/projects/node_modules/commander/index.js": "module.exports = 0", - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "commander": "export let commander: number", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Assert(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/commander/index.d.ts") != nil) - assert.Assert(t, program.GetSourceFile("/user/username/projects/node_modules/commander/index.js") == nil) - }) - - t.Run("expired cache entry (inferred project, should install typings)", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": "export const x = 10;", - projecttestutil.TestTypingsLocation + "/package.json": `{ - "dependencies": { - "types-registry": "^0.1.317" - }, - "devDependencies": { - "@types/jquery": "^1.0.0" - } - }`, - projecttestutil.TestTypingsLocation + "/package-lock.json": `{ - "dependencies": { - "@types/jquery": { - "version": "1.0.0" - } - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": "export const y = 10", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Equal(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts").Text(), "export const y = 10") - }) - - t.Run("non-expired cache entry (inferred project, should not install typings)", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": "export const x = 10;", - projecttestutil.TestTypingsLocation + "/package.json": `{ - "dependencies": { - "types-registry": "^0.1.317" - }, - "devDependencies": { - "@types/jquery": "^1.3.0" - } - }`, - projecttestutil.TestTypingsLocation + "/package-lock.json": `{ - "dependencies": { - "@types/jquery": { - "version": "1.3.0" - } - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"jquery"}, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 1 typings", - }) - program := p.GetProgram() - assert.Equal(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts").Text(), "export const x = 10;") - }) - - t.Run("expired cache entry (inferred project, should install typings) lockfile3", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": "export const x = 10;", - projecttestutil.TestTypingsLocation + "/package.json": `{ - "dependencies": { - "types-registry": "^0.1.317" - }, - "devDependencies": { - "@types/jquery": "^1.0.0" - } - }`, - projecttestutil.TestTypingsLocation + "/package-lock.json": `{ - "packages": { - "node_modules/@types/jquery": { - "version": "1.0.0" - } - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - PackageToFile: map[string]string{ - "jquery": "export const y = 10", - }, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Success", - }) - program := p.GetProgram() - assert.Equal(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts").Text(), "export const y = 10") - }) - - t.Run("non-expired cache entry (inferred project, should not install typings) lockfile3", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/user/username/projects/project/app.js": "", - "/user/username/projects/project/package.json": `{ - "name": "test", - "dependencies": { - "jquery": "^3.1.0" - } - }`, - projecttestutil.TestTypingsLocation + "/node_modules/@types/jquery/index.d.ts": "export const x = 10;", - projecttestutil.TestTypingsLocation + "/package.json": `{ - "dependencies": { - "types-registry": "^0.1.317" - }, - "devDependencies": { - "@types/jquery": "^1.3.0" - } - }`, - projecttestutil.TestTypingsLocation + "/package-lock.json": `{ - "packages": { - "node_modules/@types/jquery": { - "version": "1.3.0" - } - } - }`, - } - service, host := projecttestutil.Setup(files, &projecttestutil.TestTypingsInstaller{ - TestTypingsInstallerOptions: projecttestutil.TestTypingsInstallerOptions{ - TypesRegistry: []string{"jquery"}, - }, - }) - - service.OpenFile("/user/username/projects/project/app.js", files["/user/username/projects/project/app.js"], core.ScriptKindJS, "") - _, p := service.EnsureDefaultProjectForFile("/user/username/projects/project/app.js") - // Order is determinate since second install will run only after completing first one - status := <-host.ServiceOptions.InstallStatus - assert.Equal(t, status, project.TypingsInstallerStatus{ - RequestId: 1, - Project: p, - Status: "Skipped 1 typings", - }) - program := p.GetProgram() - assert.Equal(t, program.GetSourceFile(projecttestutil.TestTypingsLocation+"/node_modules/@types/jquery/index.d.ts").Text(), "export const x = 10;") - }) -} diff --git a/internal/project/background/queue.go b/internal/project/background/queue.go new file mode 100644 index 0000000000..4a425e569e --- /dev/null +++ b/internal/project/background/queue.go @@ -0,0 +1,45 @@ +package background + +import ( + "context" + "sync" +) + +// Queue manages background tasks execution +type Queue struct { + wg sync.WaitGroup + mu sync.RWMutex + closed bool +} + +// NewQueue creates a new background queue for managing background tasks execution. +func NewQueue() *Queue { + return &Queue{} +} + +func (q *Queue) Enqueue(ctx context.Context, fn func(context.Context)) { + q.mu.RLock() + if q.closed { + q.mu.RUnlock() + return + } + q.mu.RUnlock() + + q.wg.Add(1) + go func() { + defer q.wg.Done() + fn(ctx) + }() +} + +// Wait waits for all active tasks to complete. +// It does not prevent new tasks from being enqueued while waiting. +func (q *Queue) Wait() { + q.wg.Wait() +} + +func (q *Queue) Close() { + q.mu.Lock() + q.closed = true + q.mu.Unlock() +} diff --git a/internal/project/background/queue_test.go b/internal/project/background/queue_test.go new file mode 100644 index 0000000000..68a8373f47 --- /dev/null +++ b/internal/project/background/queue_test.go @@ -0,0 +1,91 @@ +package background_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/microsoft/typescript-go/internal/project/background" + "gotest.tools/v3/assert" +) + +func TestQueue(t *testing.T) { + t.Parallel() + t.Run("BasicEnqueue", func(t *testing.T) { + t.Parallel() + q := background.NewQueue() + defer q.Close() + + executed := false + q.Enqueue(context.Background(), func(ctx context.Context) { + executed = true + }) + + q.Wait() + + assert.Check(t, executed) + }) + + t.Run("MultipleTasksExecution", func(t *testing.T) { + t.Parallel() + q := background.NewQueue() + defer q.Close() + + var counter int64 + numTasks := 10 + + for range numTasks { + q.Enqueue(context.Background(), func(ctx context.Context) { + atomic.AddInt64(&counter, 1) + }) + } + + q.Wait() + + assert.Equal(t, atomic.LoadInt64(&counter), int64(numTasks)) + }) + + t.Run("NestedEnqueue", func(t *testing.T) { + t.Parallel() + q := background.NewQueue() + defer q.Close() + + var executed []string + var mu sync.Mutex + + q.Enqueue(context.Background(), func(ctx context.Context) { + mu.Lock() + executed = append(executed, "parent") + mu.Unlock() + + q.Enqueue(ctx, func(childCtx context.Context) { + mu.Lock() + executed = append(executed, "child") + mu.Unlock() + }) + }) + + q.Wait() + + mu.Lock() + defer mu.Unlock() + + assert.Equal(t, len(executed), 2) + }) + + t.Run("ClosedQueueRejectsNewTasks", func(t *testing.T) { + t.Parallel() + q := background.NewQueue() + q.Close() + + executed := false + q.Enqueue(context.Background(), func(ctx context.Context) { + executed = true + }) + + q.Wait() + + assert.Check(t, !executed, "Task should not execute after queue is closed") + }) +} diff --git a/internal/project/client.go b/internal/project/client.go new file mode 100644 index 0000000000..e223389eb6 --- /dev/null +++ b/internal/project/client.go @@ -0,0 +1,13 @@ +package project + +import ( + "context" + + "github.com/microsoft/typescript-go/internal/lsp/lsproto" +) + +type Client interface { + WatchFiles(ctx context.Context, id WatcherID, watchers []*lsproto.FileSystemWatcher) error + UnwatchFiles(ctx context.Context, id WatcherID) error + RefreshDiagnostics(ctx context.Context) error +} diff --git a/internal/project/compilerhost.go b/internal/project/compilerhost.go new file mode 100644 index 0000000000..1d6bf81bfe --- /dev/null +++ b/internal/project/compilerhost.go @@ -0,0 +1,176 @@ +package project + +import ( + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/compiler" + "github.com/microsoft/typescript-go/internal/ls" + "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" +) + +var _ compiler.CompilerHost = (*compilerHost)(nil) + +type compilerHost struct { + configFilePath tspath.Path + currentDirectory string + sessionOptions *SessionOptions + + fs *snapshotFSBuilder + compilerFS *compilerFS + configFileRegistry *ConfigFileRegistry + + project *Project + builder *projectCollectionBuilder + logger *logging.LogTree +} + +func newCompilerHost( + currentDirectory string, + project *Project, + builder *projectCollectionBuilder, + logger *logging.LogTree, +) *compilerHost { + return &compilerHost{ + configFilePath: project.configFilePath, + currentDirectory: currentDirectory, + sessionOptions: builder.sessionOptions, + + compilerFS: &compilerFS{source: builder.fs}, + + fs: builder.fs, + project: project, + builder: builder, + logger: logger, + } +} + +// freeze clears references to mutable state to make the compilerHost safe for use +// after the snapshot has been finalized. See the usage in snapshot.go for more details. +func (c *compilerHost) freeze(snapshotFS *snapshotFS, configFileRegistry *ConfigFileRegistry) { + if c.builder == nil { + panic("freeze can only be called once") + } + c.compilerFS.source = snapshotFS + c.configFileRegistry = configFileRegistry + c.fs = nil + c.builder = nil + c.project = nil + c.logger = nil +} + +func (c *compilerHost) ensureAlive() { + if c.builder == nil || c.project == nil { + panic("method must not be called after snapshot initialization") + } +} + +// DefaultLibraryPath implements compiler.CompilerHost. +func (c *compilerHost) DefaultLibraryPath() string { + return c.sessionOptions.DefaultLibraryPath +} + +// FS implements compiler.CompilerHost. +func (c *compilerHost) FS() vfs.FS { + return c.compilerFS +} + +// GetCurrentDirectory implements compiler.CompilerHost. +func (c *compilerHost) GetCurrentDirectory() string { + return c.currentDirectory +} + +// GetResolvedProjectReference implements compiler.CompilerHost. +func (c *compilerHost) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { + if c.builder == nil { + return c.configFileRegistry.GetConfig(path) + } else { + return c.builder.configFileRegistryBuilder.acquireConfigForProject(fileName, path, c.project, c.logger) + } +} + +// GetSourceFile implements compiler.CompilerHost. GetSourceFile increments +// the ref count of source files it acquires in the parseCache. There should +// be a corresponding release for each call made. +func (c *compilerHost) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { + c.ensureAlive() + if fh := c.fs.GetFileByPath(opts.FileName, opts.Path); fh != nil { + return c.builder.parseCache.Acquire(fh, opts, fh.Kind()) + } + return nil +} + +func (c *compilerHost) GetLineMap(fileName string) *ls.LineMap { + if fh := c.compilerFS.source.GetFile(fileName); fh != nil { + return fh.LineMap() + } + return nil +} + +// Trace implements compiler.CompilerHost. +func (c *compilerHost) Trace(msg string) { + panic("unimplemented") +} + +var _ vfs.FS = (*compilerFS)(nil) + +type compilerFS struct { + source FileSource +} + +// DirectoryExists implements vfs.FS. +func (fs *compilerFS) DirectoryExists(path string) bool { + return fs.source.FS().DirectoryExists(path) +} + +// FileExists implements vfs.FS. +func (fs *compilerFS) FileExists(path string) bool { + if fh := fs.source.GetFile(path); fh != nil { + return true + } + return fs.source.FS().FileExists(path) +} + +// GetAccessibleEntries implements vfs.FS. +func (fs *compilerFS) GetAccessibleEntries(path string) vfs.Entries { + return fs.source.FS().GetAccessibleEntries(path) +} + +// ReadFile implements vfs.FS. +func (fs *compilerFS) ReadFile(path string) (contents string, ok bool) { + if fh := fs.source.GetFile(path); fh != nil { + return fh.Content(), true + } + return "", false +} + +// Realpath implements vfs.FS. +func (fs *compilerFS) Realpath(path string) string { + return fs.source.FS().Realpath(path) +} + +// Stat implements vfs.FS. +func (fs *compilerFS) Stat(path string) vfs.FileInfo { + return fs.source.FS().Stat(path) +} + +// UseCaseSensitiveFileNames implements vfs.FS. +func (fs *compilerFS) UseCaseSensitiveFileNames() bool { + return fs.source.FS().UseCaseSensitiveFileNames() +} + +// WalkDir implements vfs.FS. +func (fs *compilerFS) WalkDir(root string, walkFn vfs.WalkDirFunc) error { + panic("unimplemented") +} + +// WriteFile implements vfs.FS. +func (fs *compilerFS) WriteFile(path string, data string, writeByteOrderMark bool) error { + panic("unimplemented") +} + +// Remove implements vfs.FS. +func (fs *compilerFS) Remove(path string) error { + panic("unimplemented") +} diff --git a/internal/project/configfilechanges_test.go b/internal/project/configfilechanges_test.go new file mode 100644 index 0000000000..7b4de6dc2c --- /dev/null +++ b/internal/project/configfilechanges_test.go @@ -0,0 +1,156 @@ +package project_test + +import ( + "context" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestConfigFileChanges(t *testing.T) { + t.Parallel() + + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + files := map[string]any{ + "/tsconfig.base.json": `{"compilerOptions": {"strict": true}}`, + "/src/tsconfig.json": `{"extends": "../tsconfig.base.json", "compilerOptions": {"target": "es6"}, "references": [{"path": "../utils"}]}`, + "/src/index.ts": `console.log("Hello, world!");`, + "/src/subfolder/foo.ts": `export const foo = "bar";`, + + "/utils/tsconfig.json": `{"compilerOptions": {"composite": true}}`, + "/utils/index.ts": `console.log("Hello, test!");`, + } + + t.Run("should update program options on config file change", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + err := utils.FS().WriteFile("/src/tsconfig.json", `{"extends": "../tsconfig.base.json", "compilerOptions": {"target": "esnext"}, "references": [{"path": "../utils"}]}`, false /*writeByteOrderMark*/) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), + Type: lsproto.FileChangeTypeChanged, + }, + }) + + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Target, core.ScriptTargetESNext) + }) + + t.Run("should update project on extended config file change", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + err := utils.FS().WriteFile("/tsconfig.base.json", `{"compilerOptions": {"strict": false}}`, false /*writeByteOrderMark*/) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///tsconfig.base.json"), + Type: lsproto.FileChangeTypeChanged, + }, + }) + + ls, err := session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + assert.Equal(t, ls.GetProgram().Options().Strict, core.TSFalse) + }) + + t.Run("should update project on referenced config file change", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + snapshotBefore, release := session.Snapshot() + defer release() + + err := utils.FS().WriteFile("/utils/tsconfig.json", `{"compilerOptions": {"composite": true, "target": "esnext"}}`, false /*writeByteOrderMark*/) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///utils/tsconfig.json"), + Type: lsproto.FileChangeTypeChanged, + }, + }) + + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + snapshotAfter, release := session.Snapshot() + defer release() + assert.Assert(t, snapshotAfter != snapshotBefore, "Snapshot should be updated after config file change") + }) + + t.Run("should close project on config file deletion", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + err := utils.FS().Remove("/src/tsconfig.json") + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///src/tsconfig.json"), + Type: lsproto.FileChangeTypeDeleted, + }, + }) + + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/index.ts")) + assert.NilError(t, err) + snapshot, release := session.Snapshot() + defer release() + assert.Assert(t, len(snapshot.ProjectCollection.Projects()) == 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + }) + + t.Run("config file creation then deletion", func(t *testing.T) { + t.Parallel() + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///src/subfolder/foo.ts", 1, files["/src/subfolder/foo.ts"].(string), lsproto.LanguageKindTypeScript) + + err := utils.FS().WriteFile("/src/subfolder/tsconfig.json", `{}`, false /*writeByteOrderMark*/) + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///src/subfolder/tsconfig.json"), + Type: lsproto.FileChangeTypeCreated, + }, + }) + + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/subfolder/foo.ts")) + assert.NilError(t, err) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + assert.Equal(t, snapshot.GetDefaultProject(lsproto.DocumentUri("file:///src/subfolder/foo.ts")).Name(), "/src/subfolder/tsconfig.json") + + err = utils.FS().Remove("/src/subfolder/tsconfig.json") + assert.NilError(t, err) + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Uri: lsproto.DocumentUri("file:///src/subfolder/tsconfig.json"), + Type: lsproto.FileChangeTypeDeleted, + }, + }) + + _, err = session.GetLanguageService(context.Background(), lsproto.DocumentUri("file:///src/subfolder/foo.ts")) + assert.NilError(t, err) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, snapshot.GetDefaultProject(lsproto.DocumentUri("file:///src/subfolder/foo.ts")).Name(), "/src/tsconfig.json") + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) // Old project will be cleaned up on next file open + + session.DidOpenFile(context.Background(), "file:///src/index.ts", 1, files["/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + }) +} diff --git a/internal/project/configfileregistry.go b/internal/project/configfileregistry.go index 26b9060ba9..bae60b66c9 100644 --- a/internal/project/configfileregistry.go +++ b/internal/project/configfileregistry.go @@ -1,277 +1,125 @@ package project import ( - "context" - "fmt" "maps" - "slices" - "sync" - "github.com/microsoft/typescript-go/internal/collections" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/tsoptions" "github.com/microsoft/typescript-go/internal/tspath" ) -type ConfigFileEntry struct { - mu sync.RWMutex - commandLine *tsoptions.ParsedCommandLine - projects collections.Set[*Project] - infos collections.Set[*ScriptInfo] - pendingReload PendingReload - rootFilesWatch *watchedFiles[[]string] -} - -type ExtendedConfigFileEntry struct { - mu sync.Mutex - configFiles collections.Set[tspath.Path] -} - type ConfigFileRegistry struct { - Host ProjectHost - defaultProjectFinder *defaultProjectFinder - ConfigFiles collections.SyncMap[tspath.Path, *ConfigFileEntry] - ExtendedConfigCache collections.SyncMap[tspath.Path, *tsoptions.ExtendedConfigCacheEntry] - ExtendedConfigsUsedBy collections.SyncMap[tspath.Path, *ExtendedConfigFileEntry] -} - -func (e *ConfigFileEntry) SetPendingReload(level PendingReload) bool { - if e.pendingReload < level { - e.pendingReload = level - return true - } - return false -} - -var _ watchFileHost = (*configFileWatchHost)(nil) - -type configFileWatchHost struct { - fileName string - host ProjectHost -} - -func (h *configFileWatchHost) Name() string { - return h.fileName -} - -func (c *configFileWatchHost) Client() Client { - return c.host.Client() -} - -func (c *configFileWatchHost) Log(message string) { - c.host.Log(message) -} - -func (c *ConfigFileRegistry) releaseConfig(path tspath.Path, project *Project) { - entry, ok := c.ConfigFiles.Load(path) - if !ok { - return - } - entry.mu.Lock() - defer entry.mu.Unlock() - entry.projects.Delete(project) -} - -func (c *ConfigFileRegistry) acquireConfig(fileName string, path tspath.Path, project *Project, info *ScriptInfo) *tsoptions.ParsedCommandLine { - entry, ok := c.ConfigFiles.Load(path) - if !ok { - // Create parsed command line - config, _ := tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache) - var rootFilesWatch *watchedFiles[[]string] - client := c.Host.Client() - if c.Host.IsWatchEnabled() && client != nil { - rootFilesWatch = newWatchedFiles(&configFileWatchHost{fileName: fileName, host: c.Host}, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity, "root files") - } - entry, _ = c.ConfigFiles.LoadOrStore(path, &ConfigFileEntry{ - commandLine: config, - pendingReload: PendingReloadFull, - rootFilesWatch: rootFilesWatch, - }) - } - entry.mu.Lock() - defer entry.mu.Unlock() - if project != nil { - entry.projects.Add(project) - } else if info != nil { - entry.infos.Add(info) - } - if entry.pendingReload == PendingReloadNone { - return entry.commandLine - } - switch entry.pendingReload { - case PendingReloadFileNames: - entry.commandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(entry.commandLine, c.Host.FS()) - case PendingReloadFull: - oldCommandLine := entry.commandLine - entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c.Host, &c.ExtendedConfigCache) - c.updateExtendedConfigsUsedBy(path, entry, oldCommandLine) - c.updateRootFilesWatch(fileName, entry) - } - entry.pendingReload = PendingReloadNone - return entry.commandLine -} - -func (c *ConfigFileRegistry) getConfig(path tspath.Path) *tsoptions.ParsedCommandLine { - entry, ok := c.ConfigFiles.Load(path) - if ok { - entry.mu.RLock() - defer entry.mu.RUnlock() + // configs is a map of config file paths to their entries. + configs map[tspath.Path]*configFileEntry + // configFileNames is a map of open file paths to information + // about their ancestor config file names. It is only used as + // a cache during + configFileNames map[tspath.Path]*configFileNames +} + +type configFileEntry struct { + pendingReload PendingReload + commandLine *tsoptions.ParsedCommandLine + // retainingProjects is the set of projects that have called acquireConfig + // without releasing it. A config file entry may be acquired by a project + // either because it is the config for that project or because it is the + // config for a referenced project. + retainingProjects map[tspath.Path]struct{} + // retainingOpenFiles is the set of open files that caused this config to + // load during project collection building. This config file may or may not + // end up being the config for the default project for these files, but + // determining the default project loaded this config as a candidate, so + // subsequent calls to `projectCollectionBuilder.findDefaultConfiguredProject` + // will use this config as part of the search, so it must be retained. + retainingOpenFiles map[tspath.Path]struct{} + // retainingConfigs is the set of config files that extend this one. This + // provides a cheap reverse mapping for a project config's + // `commandLine.ExtendedSourceFiles()` that can be used to notify the + // extending projects when this config changes. An extended config file may + // or may not also be used directly by a project, so it's possible that + // when this is set, no other fields will be used. + retainingConfigs map[tspath.Path]struct{} + // rootFilesWatch is a watch for the root files of this config file. + rootFilesWatch *WatchedFiles[[]string] +} + +func newConfigFileEntry(fileName string) *configFileEntry { + return &configFileEntry{ + pendingReload: PendingReloadFull, + rootFilesWatch: NewWatchedFiles( + "root files for "+fileName, + lsproto.WatchKindCreate|lsproto.WatchKindChange|lsproto.WatchKindDelete, + core.Identity, + ), + } +} + +func newExtendedConfigFileEntry(extendingConfigPath tspath.Path) *configFileEntry { + return &configFileEntry{ + pendingReload: PendingReloadFull, + retainingConfigs: map[tspath.Path]struct{}{extendingConfigPath: {}}, + } +} + +func (e *configFileEntry) Clone() *configFileEntry { + return &configFileEntry{ + pendingReload: e.pendingReload, + commandLine: e.commandLine, + // !!! eagerly cloning these maps makes everything more convenient, + // but it could be avoided if needed. + retainingProjects: maps.Clone(e.retainingProjects), + retainingOpenFiles: maps.Clone(e.retainingOpenFiles), + retainingConfigs: maps.Clone(e.retainingConfigs), + rootFilesWatch: e.rootFilesWatch, + } +} + +func (c *ConfigFileRegistry) GetConfig(path tspath.Path) *tsoptions.ParsedCommandLine { + if entry, ok := c.configs[path]; ok { return entry.commandLine } return nil } -func (c *ConfigFileRegistry) releaseConfigsForInfo(info *ScriptInfo) { - c.ConfigFiles.Range(func(path tspath.Path, entry *ConfigFileEntry) bool { - entry.mu.Lock() - entry.infos.Delete(info) - entry.mu.Unlock() - return true - }) -} - -func (c *ConfigFileRegistry) updateRootFilesWatch(fileName string, entry *ConfigFileEntry) { - if entry.rootFilesWatch == nil { - return - } - - wildcardGlobs := entry.commandLine.WildcardDirectories() - rootFileGlobs := make([]string, 0, len(wildcardGlobs)+1+len(entry.commandLine.ExtendedSourceFiles())) - rootFileGlobs = append(rootFileGlobs, fileName) - for _, extendedConfig := range entry.commandLine.ExtendedSourceFiles() { - rootFileGlobs = append(rootFileGlobs, extendedConfig) - } - for dir, recursive := range wildcardGlobs { - rootFileGlobs = append(rootFileGlobs, fmt.Sprintf("%s/%s", tspath.NormalizePath(dir), core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern))) - } - for _, fileName := range entry.commandLine.LiteralFileNames() { - rootFileGlobs = append(rootFileGlobs, fileName) - } - entry.rootFilesWatch.update(context.Background(), rootFileGlobs) -} - -func (c *ConfigFileRegistry) updateExtendedConfigsUsedBy(path tspath.Path, entry *ConfigFileEntry, oldCommandLine *tsoptions.ParsedCommandLine) { - extendedConfigs := entry.commandLine.ExtendedSourceFiles() - newConfigs := make([]tspath.Path, 0, len(extendedConfigs)) - for _, extendedConfig := range extendedConfigs { - extendedPath := tspath.ToPath(extendedConfig, c.Host.GetCurrentDirectory(), c.Host.FS().UseCaseSensitiveFileNames()) - newConfigs = append(newConfigs, extendedPath) - extendedEntry, _ := c.ExtendedConfigsUsedBy.LoadOrStore(extendedPath, &ExtendedConfigFileEntry{ - mu: sync.Mutex{}, - }) - extendedEntry.mu.Lock() - extendedEntry.configFiles.Add(path) - extendedEntry.mu.Unlock() - } - for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() { - extendedPath := tspath.ToPath(extendedConfig, c.Host.GetCurrentDirectory(), c.Host.FS().UseCaseSensitiveFileNames()) - if !slices.Contains(newConfigs, extendedPath) { - extendedEntry, _ := c.ExtendedConfigsUsedBy.Load(extendedPath) - extendedEntry.mu.Lock() - extendedEntry.configFiles.Delete(path) - if extendedEntry.configFiles.Len() == 0 { - c.ExtendedConfigsUsedBy.Delete(extendedPath) - c.ExtendedConfigCache.Delete(extendedPath) - } - extendedEntry.mu.Unlock() - } +func (c *ConfigFileRegistry) GetConfigFileName(path tspath.Path) string { + if entry, ok := c.configFileNames[path]; ok { + return entry.nearestConfigFileName } + return "" } -func (c *ConfigFileRegistry) onWatchedFilesChanged(path tspath.Path, changeKind lsproto.FileChangeType) (err error, handled bool) { - if c.onConfigChange(path, changeKind) { - handled = true - } - - if entry, loaded := c.ExtendedConfigsUsedBy.Load(path); loaded { - entry.mu.Lock() - for configFilePath := range entry.configFiles.Keys() { - if c.onConfigChange(configFilePath, changeKind) { - handled = true - } - } - entry.mu.Unlock() +func (c *ConfigFileRegistry) GetAncestorConfigFileName(path tspath.Path, higherThanConfig string) string { + if entry, ok := c.configFileNames[path]; ok { + return entry.ancestors[higherThanConfig] } - return err, handled + return "" } -func (c *ConfigFileRegistry) onConfigChange(path tspath.Path, changeKind lsproto.FileChangeType) bool { - entry, ok := c.ConfigFiles.Load(path) - if !ok { - return false +// clone creates a shallow copy of the configFileRegistry. +func (c *ConfigFileRegistry) clone() *ConfigFileRegistry { + return &ConfigFileRegistry{ + configs: maps.Clone(c.configs), + configFileNames: maps.Clone(c.configFileNames), } - entry.mu.Lock() - hasSet := entry.SetPendingReload(PendingReloadFull) - var infos map[*ScriptInfo]struct{} - var projects map[*Project]struct{} - if hasSet { - infos = maps.Clone(entry.infos.Keys()) - projects = maps.Clone(entry.projects.Keys()) - } - entry.mu.Unlock() - if !hasSet { - return false - } - for info := range infos { - delete(c.defaultProjectFinder.configFileForOpenFiles, info.Path()) - delete(c.defaultProjectFinder.configFilesAncestorForOpenFiles, info.Path()) - } - for project := range projects { - if project.configFilePath == path { - switch changeKind { - case lsproto.FileChangeTypeCreated: - fallthrough - case lsproto.FileChangeTypeChanged: - project.deferredClose = false - project.SetPendingReload(PendingReloadFull) - case lsproto.FileChangeTypeDeleted: - project.deferredClose = true - } - } else { - project.markAsDirty() - } - } - return true } -func (c *ConfigFileRegistry) tryInvokeWildCardDirectories(fileName string, path tspath.Path) { - configFiles := c.ConfigFiles.ToMap() - for configPath, entry := range configFiles { - entry.mu.Lock() - hasSet := false - if entry.commandLine != nil && entry.pendingReload == PendingReloadNone && entry.commandLine.MatchesFileName(fileName) { - hasSet = entry.SetPendingReload(PendingReloadFileNames) - } - var projects map[*Project]struct{} - if hasSet { - projects = maps.Clone(entry.projects.Keys()) - } - entry.mu.Unlock() - if hasSet { - for project := range projects { - if project.configFilePath == configPath { - project.SetPendingReload(PendingReloadFileNames) - } else { - project.markAsDirty() - } - } - } - } +type configFileNames struct { + // nearestConfigFileName is the file name of the nearest ancestor config file. + nearestConfigFileName string + // ancestors is a map from one ancestor config file path to the next. + // For example, if `/a`, `/a/b`, and `/a/b/c` all contain config files, + // the fully loaded map will look like: + // { + // "/a/b/c/tsconfig.json": "/a/b/tsconfig.json", + // "/a/b/tsconfig.json": "/a/tsconfig.json" + // } + ancestors map[string]string } -func (c *ConfigFileRegistry) cleanup(toRemoveConfigs map[tspath.Path]*ConfigFileEntry) { - for path, entry := range toRemoveConfigs { - entry.mu.Lock() - if entry.projects.Len() == 0 && entry.infos.Len() == 0 { - c.ConfigFiles.Delete(path) - commandLine := entry.commandLine - entry.commandLine = nil - c.updateExtendedConfigsUsedBy(path, entry, commandLine) - if entry.rootFilesWatch != nil { - entry.rootFilesWatch.update(context.Background(), nil) - } - } - entry.mu.Unlock() +func (c *configFileNames) Clone() *configFileNames { + return &configFileNames{ + nearestConfigFileName: c.nearestConfigFileName, + ancestors: maps.Clone(c.ancestors), } } diff --git a/internal/project/configfileregistrybuilder.go b/internal/project/configfileregistrybuilder.go new file mode 100644 index 0000000000..f01090ed0c --- /dev/null +++ b/internal/project/configfileregistrybuilder.go @@ -0,0 +1,508 @@ +package project + +import ( + "fmt" + "maps" + "slices" + "strings" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/project/dirty" + "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" +) + +var ( + _ tsoptions.ParseConfigHost = (*configFileRegistryBuilder)(nil) + _ tsoptions.ExtendedConfigCache = (*configFileRegistryBuilder)(nil) +) + +// configFileRegistryBuilder tracks changes made on top of a previous +// configFileRegistry, producing a new clone with `finalize()` after +// all changes have been made. +type configFileRegistryBuilder struct { + fs *snapshotFSBuilder + extendedConfigCache *extendedConfigCache + sessionOptions *SessionOptions + + base *ConfigFileRegistry + configs *dirty.SyncMap[tspath.Path, *configFileEntry] + configFileNames *dirty.Map[tspath.Path, *configFileNames] +} + +func newConfigFileRegistryBuilder( + fs *snapshotFSBuilder, + oldConfigFileRegistry *ConfigFileRegistry, + extendedConfigCache *extendedConfigCache, + sessionOptions *SessionOptions, + logger *logging.LogTree, +) *configFileRegistryBuilder { + return &configFileRegistryBuilder{ + fs: fs, + base: oldConfigFileRegistry, + sessionOptions: sessionOptions, + extendedConfigCache: extendedConfigCache, + + configs: dirty.NewSyncMap(oldConfigFileRegistry.configs, nil), + configFileNames: dirty.NewMap(oldConfigFileRegistry.configFileNames), + } +} + +// Finalize creates a new configFileRegistry based on the changes made in the builder. +// If no changes were made, it returns the original base registry. +func (c *configFileRegistryBuilder) Finalize() *ConfigFileRegistry { + var changed bool + newRegistry := c.base + ensureCloned := func() { + if !changed { + newRegistry = newRegistry.clone() + changed = true + } + } + + if configs, changedConfigs := c.configs.Finalize(); changedConfigs { + ensureCloned() + newRegistry.configs = configs + } + + if configFileNames, changedNames := c.configFileNames.Finalize(); changedNames { + ensureCloned() + newRegistry.configFileNames = configFileNames + } + + return newRegistry +} + +func (c *configFileRegistryBuilder) findOrAcquireConfigForOpenFile( + configFileName string, + configFilePath tspath.Path, + openFilePath tspath.Path, + loadKind projectLoadKind, + logger *logging.LogTree, +) *tsoptions.ParsedCommandLine { + switch loadKind { + case projectLoadKindFind: + if entry, ok := c.configs.Load(configFilePath); ok { + return entry.Value().commandLine + } + return nil + case projectLoadKindCreate: + return c.acquireConfigForOpenFile(configFileName, configFilePath, openFilePath, logger) + default: + panic(fmt.Sprintf("unknown project load kind: %d", loadKind)) + } +} + +// reloadIfNeeded updates the command line of the config file entry based on its +// pending reload state. This function should only be called from within the +// Change() method of a dirty map entry. +func (c *configFileRegistryBuilder) reloadIfNeeded(entry *configFileEntry, fileName string, path tspath.Path, logger *logging.LogTree) { + switch entry.pendingReload { + case PendingReloadFileNames: + logger.Log("Reloading file names for config: " + fileName) + entry.commandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(entry.commandLine, c.fs.fs) + case PendingReloadFull: + logger.Log("Loading config file: " + fileName) + entry.commandLine, _ = tsoptions.GetParsedCommandLineOfConfigFilePath(fileName, path, nil, c, c) + c.updateExtendingConfigs(path, entry.commandLine, entry.commandLine) + c.updateRootFilesWatch(fileName, entry) + logger.Log("Finished loading config file") + default: + return + } + entry.pendingReload = PendingReloadNone +} + +func (c *configFileRegistryBuilder) updateExtendingConfigs(extendingConfigPath tspath.Path, newCommandLine *tsoptions.ParsedCommandLine, oldCommandLine *tsoptions.ParsedCommandLine) { + var newExtendedConfigPaths collections.Set[tspath.Path] + if newCommandLine != nil { + for _, extendedConfig := range newCommandLine.ExtendedSourceFiles() { + extendedConfigPath := c.fs.toPath(extendedConfig) + newExtendedConfigPaths.Add(extendedConfigPath) + entry, loaded := c.configs.LoadOrStore(extendedConfigPath, newExtendedConfigFileEntry(extendingConfigPath)) + if loaded { + entry.ChangeIf( + func(config *configFileEntry) bool { + _, alreadyRetaining := config.retainingConfigs[extendingConfigPath] + return !alreadyRetaining + }, + func(config *configFileEntry) { + if config.retainingConfigs == nil { + config.retainingConfigs = make(map[tspath.Path]struct{}) + } + config.retainingConfigs[extendingConfigPath] = struct{}{} + }, + ) + } + } + } + if oldCommandLine != nil { + for _, extendedConfig := range oldCommandLine.ExtendedSourceFiles() { + extendedConfigPath := c.fs.toPath(extendedConfig) + if newExtendedConfigPaths.Has(extendedConfigPath) { + continue + } + if entry, ok := c.configs.Load(extendedConfigPath); ok { + entry.ChangeIf( + func(config *configFileEntry) bool { + _, exists := config.retainingConfigs[extendingConfigPath] + return exists + }, + func(config *configFileEntry) { + delete(config.retainingConfigs, extendingConfigPath) + }, + ) + } + } + } +} + +func (c *configFileRegistryBuilder) updateRootFilesWatch(fileName string, entry *configFileEntry) { + if entry.rootFilesWatch == nil { + return + } + + wildcardGlobs := entry.commandLine.WildcardDirectories() + rootFileGlobs := make([]string, 0, len(wildcardGlobs)+1+len(entry.commandLine.ExtendedSourceFiles())) + rootFileGlobs = append(rootFileGlobs, fileName) + for _, extendedConfig := range entry.commandLine.ExtendedSourceFiles() { + rootFileGlobs = append(rootFileGlobs, extendedConfig) + } + for dir, recursive := range wildcardGlobs { + rootFileGlobs = append(rootFileGlobs, fmt.Sprintf("%s/%s", tspath.NormalizePath(dir), core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern))) + } + for _, fileName := range entry.commandLine.LiteralFileNames() { + rootFileGlobs = append(rootFileGlobs, fileName) + } + + slices.Sort(rootFileGlobs) + entry.rootFilesWatch = entry.rootFilesWatch.Clone(rootFileGlobs) +} + +// acquireConfigForProject loads a config file entry from the cache, or parses it if not already +// cached, then adds the project (if provided) to `retainingProjects` to keep it alive +// in the cache. Each `acquireConfigForProject` call that passes a `project` should be accompanied +// by an eventual `releaseConfigForProject` call with the same project. +func (c *configFileRegistryBuilder) acquireConfigForProject(fileName string, path tspath.Path, project *Project, logger *logging.LogTree) *tsoptions.ParsedCommandLine { + entry, _ := c.configs.LoadOrStore(path, newConfigFileEntry(fileName)) + var needsRetainProject bool + entry.ChangeIf( + func(config *configFileEntry) bool { + _, alreadyRetaining := config.retainingProjects[project.configFilePath] + needsRetainProject = !alreadyRetaining + return needsRetainProject || config.pendingReload != PendingReloadNone + }, + func(config *configFileEntry) { + if needsRetainProject { + if config.retainingProjects == nil { + config.retainingProjects = make(map[tspath.Path]struct{}) + } + config.retainingProjects[project.configFilePath] = struct{}{} + } + c.reloadIfNeeded(config, fileName, path, logger) + }, + ) + return entry.Value().commandLine +} + +// acquireConfigForOpenFile loads a config file entry from the cache, or parses it if not already +// cached, then adds the open file to `retainingOpenFiles` to keep it alive in the cache. +// Each `acquireConfigForOpenFile` call that passes an `openFilePath` +// should be accompanied by an eventual `releaseConfigForOpenFile` call with the same open file. +func (c *configFileRegistryBuilder) acquireConfigForOpenFile(configFileName string, configFilePath tspath.Path, openFilePath tspath.Path, logger *logging.LogTree) *tsoptions.ParsedCommandLine { + entry, _ := c.configs.LoadOrStore(configFilePath, newConfigFileEntry(configFileName)) + var needsRetainOpenFile bool + entry.ChangeIf( + func(config *configFileEntry) bool { + _, alreadyRetaining := config.retainingOpenFiles[openFilePath] + needsRetainOpenFile = !alreadyRetaining + return needsRetainOpenFile || config.pendingReload != PendingReloadNone + }, + func(config *configFileEntry) { + if needsRetainOpenFile { + if config.retainingOpenFiles == nil { + config.retainingOpenFiles = make(map[tspath.Path]struct{}) + } + config.retainingOpenFiles[openFilePath] = struct{}{} + } + c.reloadIfNeeded(config, configFileName, configFilePath, logger) + }, + ) + return entry.Value().commandLine +} + +// releaseConfigForProject removes the project from the config entry. Once no projects +// or files are associated with the config entry, it will be removed on the next call to `cleanup`. +func (c *configFileRegistryBuilder) releaseConfigForProject(configFilePath tspath.Path, projectPath tspath.Path) { + if entry, ok := c.configs.Load(configFilePath); ok { + entry.ChangeIf( + func(config *configFileEntry) bool { + _, exists := config.retainingProjects[projectPath] + return exists + }, + func(config *configFileEntry) { + delete(config.retainingProjects, projectPath) + }, + ) + } +} + +// didCloseFile removes the open file from the config entry. Once no projects +// or files are associated with the config entry, it will be removed on the next call to `cleanup`. +func (c *configFileRegistryBuilder) didCloseFile(path tspath.Path) { + c.configFileNames.Delete(path) + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + entry.ChangeIf( + func(config *configFileEntry) bool { + _, ok := config.retainingOpenFiles[path] + return ok + }, + func(config *configFileEntry) { + delete(config.retainingOpenFiles, path) + }, + ) + return true + }) +} + +type changeFileResult struct { + affectedProjects map[tspath.Path]struct{} + affectedFiles map[tspath.Path]struct{} +} + +func (r changeFileResult) IsEmpty() bool { + return len(r.affectedProjects) == 0 && len(r.affectedFiles) == 0 +} + +func (c *configFileRegistryBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) changeFileResult { + var affectedProjects map[tspath.Path]struct{} + var affectedFiles map[tspath.Path]struct{} + logger.Log("Summarizing file changes") + createdFiles := make(map[tspath.Path]string, summary.Created.Len()) + createdOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Created.Len()+summary.Deleted.Len()) + createdOrChangedOrDeletedFiles := make(map[tspath.Path]struct{}, summary.Changed.Len()+summary.Deleted.Len()) + for uri := range summary.Changed.Keys() { + if tspath.ContainsIgnoredPath(string(uri)) { + continue + } + fileName := uri.FileName() + path := c.fs.toPath(fileName) + createdOrDeletedFiles[path] = struct{}{} + createdOrChangedOrDeletedFiles[path] = struct{}{} + } + for uri := range summary.Deleted.Keys() { + if tspath.ContainsIgnoredPath(string(uri)) { + continue + } + fileName := uri.FileName() + path := c.fs.toPath(fileName) + createdOrDeletedFiles[path] = struct{}{} + createdOrChangedOrDeletedFiles[path] = struct{}{} + } + for uri := range summary.Created.Keys() { + if tspath.ContainsIgnoredPath(string(uri)) { + continue + } + fileName := uri.FileName() + path := c.fs.toPath(fileName) + createdFiles[path] = fileName + createdOrDeletedFiles[path] = struct{}{} + createdOrChangedOrDeletedFiles[path] = struct{}{} + } + + // Handle closed files - this ranges over config entries and could be combined + // with the file change handling, but a separate loop is simpler and a snapshot + // change with both closing and watch changes seems rare. + for uri := range summary.Closed { + fileName := uri.FileName() + path := c.fs.toPath(fileName) + c.didCloseFile(path) + } + + // Handle changes to stored config files + logger.Log("Checking if any changed files are config files") + for path := range createdOrChangedOrDeletedFiles { + if entry, ok := c.configs.Load(path); ok { + affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(entry, logger)) + for extendingConfigPath := range entry.Value().retainingConfigs { + if extendingConfigEntry, ok := c.configs.Load(extendingConfigPath); ok { + affectedProjects = core.CopyMapInto(affectedProjects, c.handleConfigChange(extendingConfigEntry, logger)) + } + } + // This was a config file, so assume it's not also a root file + delete(createdFiles, path) + } + } + + // Handle possible root file creation + if len(createdFiles) > 0 { + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + entry.ChangeIf( + func(config *configFileEntry) bool { + if config.commandLine == nil || config.rootFilesWatch == nil || config.pendingReload != PendingReloadNone { + return false + } + logger.Logf("Checking if any of %d created files match root files for config %s", len(createdFiles), entry.Key()) + for _, fileName := range createdFiles { + parsedGlobs := config.rootFilesWatch.ParsedGlobs() + for _, g := range parsedGlobs { + if g.Match(fileName) { + return true + } + } + } + return false + }, + func(config *configFileEntry) { + config.pendingReload = PendingReloadFileNames + if affectedProjects == nil { + affectedProjects = make(map[tspath.Path]struct{}) + } + maps.Copy(affectedProjects, config.retainingProjects) + logger.Logf("Root files for config %s changed", entry.Key()) + }, + ) + return true + }) + } + + // Handle created/deleted files named "tsconfig.json" or "jsconfig.json" + for path := range createdOrDeletedFiles { + baseName := tspath.GetBaseFileName(string(path)) + if baseName == "tsconfig.json" || baseName == "jsconfig.json" { + directoryPath := path.GetDirectoryPath() + c.configFileNames.Range(func(entry *dirty.MapEntry[tspath.Path, *configFileNames]) bool { + if directoryPath.ContainsPath(entry.Key()) { + if affectedFiles == nil { + affectedFiles = make(map[tspath.Path]struct{}) + } + affectedFiles[entry.Key()] = struct{}{} + entry.Delete() + } + return true + }) + } + } + + return changeFileResult{ + affectedProjects: affectedProjects, + affectedFiles: affectedFiles, + } +} + +func (c *configFileRegistryBuilder) handleConfigChange(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry], logger *logging.LogTree) map[tspath.Path]struct{} { + var affectedProjects map[tspath.Path]struct{} + changed := entry.ChangeIf( + func(config *configFileEntry) bool { return config.pendingReload != PendingReloadFull }, + func(config *configFileEntry) { config.pendingReload = PendingReloadFull }, + ) + if changed { + logger.Logf("Config file %s changed", entry.Key()) + affectedProjects = maps.Clone(entry.Value().retainingProjects) + } + + return affectedProjects +} + +func (c *configFileRegistryBuilder) computeConfigFileName(fileName string, skipSearchInDirectoryOfFile bool, logger *logging.LogTree) string { + searchPath := tspath.GetDirectoryPath(fileName) + result, _ := tspath.ForEachAncestorDirectory(searchPath, func(directory string) (result string, stop bool) { + tsconfigPath := tspath.CombinePaths(directory, "tsconfig.json") + if !skipSearchInDirectoryOfFile && c.FS().FileExists(tsconfigPath) { + return tsconfigPath, true + } + jsconfigPath := tspath.CombinePaths(directory, "jsconfig.json") + if !skipSearchInDirectoryOfFile && c.FS().FileExists(jsconfigPath) { + return jsconfigPath, true + } + if strings.HasSuffix(directory, "/node_modules") { + return "", true + } + skipSearchInDirectoryOfFile = false + return "", false + }) + logger.Logf("computeConfigFileName:: File: %s:: Result: %s", fileName, result) + return result +} + +func (c *configFileRegistryBuilder) getConfigFileNameForFile(fileName string, path tspath.Path, loadKind projectLoadKind, logger *logging.LogTree) string { + if isDynamicFileName(fileName) { + return "" + } + + if entry, ok := c.configFileNames.Get(path); ok { + return entry.Value().nearestConfigFileName + } + + if loadKind == projectLoadKindFind { + return "" + } + + configName := c.computeConfigFileName(fileName, false, logger) + + if _, ok := c.fs.overlays[path]; ok { + c.configFileNames.Add(path, &configFileNames{ + nearestConfigFileName: configName, + }) + } + return configName +} + +func (c *configFileRegistryBuilder) getAncestorConfigFileName(fileName string, path tspath.Path, configFileName string, loadKind projectLoadKind, logger *logging.LogTree) string { + if isDynamicFileName(fileName) { + return "" + } + + entry, ok := c.configFileNames.Get(path) + if !ok { + return "" + } + if ancestorConfigName, found := entry.Value().ancestors[configFileName]; found { + return ancestorConfigName + } + + if loadKind == projectLoadKindFind { + return "" + } + + // Look for config in parent folders of config file + result := c.computeConfigFileName(configFileName, true, logger) + + if _, ok := c.fs.overlays[path]; ok { + entry.Change(func(value *configFileNames) { + if value.ancestors == nil { + value.ancestors = make(map[string]string) + } + value.ancestors[configFileName] = result + }) + } + return result +} + +// FS implements tsoptions.ParseConfigHost. +func (c *configFileRegistryBuilder) FS() vfs.FS { + return c.fs.fs +} + +// GetCurrentDirectory implements tsoptions.ParseConfigHost. +func (c *configFileRegistryBuilder) GetCurrentDirectory() string { + return c.sessionOptions.CurrentDirectory +} + +// GetExtendedConfig implements tsoptions.ExtendedConfigCache. +func (c *configFileRegistryBuilder) GetExtendedConfig(fileName string, path tspath.Path, parse func() *tsoptions.ExtendedConfigCacheEntry) *tsoptions.ExtendedConfigCacheEntry { + fh := c.fs.GetFileByPath(fileName, path) + return c.extendedConfigCache.Acquire(fh, path, parse) +} + +func (c *configFileRegistryBuilder) Cleanup() { + c.configs.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *configFileEntry]) bool { + entry.DeleteIf(func(value *configFileEntry) bool { + return len(value.retainingProjects) == 0 && len(value.retainingOpenFiles) == 0 && len(value.retainingConfigs) == 0 + }) + return true + }) +} diff --git a/internal/project/defaultprojectfinder.go b/internal/project/defaultprojectfinder.go deleted file mode 100644 index 05688bd9cd..0000000000 --- a/internal/project/defaultprojectfinder.go +++ /dev/null @@ -1,375 +0,0 @@ -package project - -import ( - "fmt" - "strings" - "sync" - - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/tsoptions" - "github.com/microsoft/typescript-go/internal/tspath" -) - -type defaultProjectFinder struct { - service *Service - configFileForOpenFiles map[tspath.Path]string // default config project for open files - configFilesAncestorForOpenFiles map[tspath.Path]map[string]string // ancestor config file for open files -} - -func (f *defaultProjectFinder) computeConfigFileName(fileName string, info *ScriptInfo, skipSearchInDirectoryOfFile bool) string { - projectRootPath := f.service.openFiles[info.path] - searchPath := tspath.GetDirectoryPath(fileName) - result, _ := tspath.ForEachAncestorDirectory(searchPath, func(directory string) (result string, stop bool) { - tsconfigPath := tspath.CombinePaths(directory, "tsconfig.json") - if !skipSearchInDirectoryOfFile && f.service.FS().FileExists(tsconfigPath) { - return tsconfigPath, true - } - jsconfigPath := tspath.CombinePaths(directory, "jsconfig.json") - if !skipSearchInDirectoryOfFile && f.service.FS().FileExists(jsconfigPath) { - return jsconfigPath, true - } - if strings.HasSuffix(directory, "/node_modules") { - return "", true - } - if projectRootPath != "" && !tspath.ContainsPath(projectRootPath, directory, f.service.comparePathsOptions) { - return "", true - } - skipSearchInDirectoryOfFile = false - return "", false - }) - f.service.logf("getConfigFileNameForFile:: File: %s ProjectRootPath: %s:: Result: %s", fileName, projectRootPath, result) - return result -} - -func (f *defaultProjectFinder) getConfigFileNameForFile(info *ScriptInfo, loadKind projectLoadKind) string { - if info.isDynamic { - return "" - } - - configName, ok := f.configFileForOpenFiles[info.path] - if ok { - return configName - } - - if loadKind == projectLoadKindFind { - return "" - } - - fileName := f.computeConfigFileName(info.fileName, info, false) - - if _, ok := f.service.openFiles[info.path]; ok { - f.configFileForOpenFiles[info.path] = fileName - } - return fileName -} - -func (f *defaultProjectFinder) getAncestorConfigFileName(info *ScriptInfo, configFileName string, loadKind projectLoadKind) string { - if info.isDynamic { - return "" - } - - ancestorConfigMap, ok := f.configFilesAncestorForOpenFiles[info.path] - if ok { - ancestorConfigName, found := ancestorConfigMap[configFileName] - if found { - return ancestorConfigName - } - } - - if loadKind == projectLoadKindFind { - return "" - } - - // Look for config in parent folders of config file - fileName := f.computeConfigFileName(configFileName, info, true) - - if _, ok := f.service.openFiles[info.path]; ok { - ancestorConfigMap, ok := f.configFilesAncestorForOpenFiles[info.path] - if !ok { - ancestorConfigMap = make(map[string]string) - f.configFilesAncestorForOpenFiles[info.path] = ancestorConfigMap - } - ancestorConfigMap[configFileName] = fileName - } - return fileName -} - -func (f *defaultProjectFinder) findOrAcquireConfig( - info *ScriptInfo, - configFileName string, - configFilePath tspath.Path, - loadKind projectLoadKind, -) *tsoptions.ParsedCommandLine { - switch loadKind { - case projectLoadKindFind: - return f.service.configFileRegistry.getConfig(configFilePath) - case projectLoadKindCreate: - return f.service.configFileRegistry.acquireConfig(configFileName, configFilePath, nil, info) - default: - panic(fmt.Sprintf("unknown project load kind: %d", loadKind)) - } -} - -func (f *defaultProjectFinder) findOrCreateProject( - configFileName string, - configFilePath tspath.Path, - loadKind projectLoadKind, -) *Project { - project := f.service.ConfiguredProject(configFilePath) - if project == nil { - if loadKind == projectLoadKindFind { - return nil - } - project = f.service.createConfiguredProject(configFileName, configFilePath) - } - return project -} - -func (f *defaultProjectFinder) isDefaultConfigForScriptInfo( - info *ScriptInfo, - configFileName string, - configFilePath tspath.Path, - config *tsoptions.ParsedCommandLine, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, -) bool { - // This currently happens only when finding project for open script info first time file is opened - // Set seen based on project if present of for config file if its not yet created - if !result.addSeenConfig(configFilePath, loadKind) { - return false - } - - // If the file is listed in root files, then only we can use this project as default project - if !config.MatchesFileName(info.fileName) { - return false - } - - // Ensure the project is uptodate and created since the file may belong to this project - project := f.findOrCreateProject(configFileName, configFilePath, loadKind) - return f.isDefaultProject(info, project, loadKind, result) -} - -func (f *defaultProjectFinder) isDefaultProject( - info *ScriptInfo, - project *Project, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, -) bool { - if project == nil { - return false - } - - // Skip already looked up projects - if !result.addSeenProject(project, loadKind) { - return false - } - // Make sure project is upto date when in create mode - if loadKind == projectLoadKindCreate { - project.updateGraph() - } - // If script info belongs to this project, use this as default config project - if project.containsScriptInfo(info) { - if !project.isSourceFromProjectReference(info) { - result.setProject(project) - return true - } else if !result.hasFallbackDefault() { - // Use this project as default if no other project is found - result.setFallbackDefault(project) - } - } - return false -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromReferences( - info *ScriptInfo, - config *tsoptions.ParsedCommandLine, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, -) bool { - if len(config.ProjectReferences()) == 0 { - return false - } - wg := core.NewWorkGroup(false) - f.tryFindDefaultConfiguredProjectFromReferencesWorker(info, config, loadKind, result, wg) - wg.RunAndWait() - return result.isDone() -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromReferencesWorker( - info *ScriptInfo, - config *tsoptions.ParsedCommandLine, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, - wg core.WorkGroup, -) { - if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() { - loadKind = projectLoadKindFind - } - for _, childConfigFileName := range config.ResolvedProjectReferencePaths() { - wg.Queue(func() { - childConfigFilePath := f.service.toPath(childConfigFileName) - childConfig := f.findOrAcquireConfig(info, childConfigFileName, childConfigFilePath, loadKind) - if childConfig == nil || f.isDefaultConfigForScriptInfo(info, childConfigFileName, childConfigFilePath, childConfig, loadKind, result) { - return - } - // Search in references if we cant find default project in current config - f.tryFindDefaultConfiguredProjectFromReferencesWorker(info, childConfig, loadKind, result, wg) - }) - } -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectFromAncestor( - info *ScriptInfo, - configFileName string, - config *tsoptions.ParsedCommandLine, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, -) bool { - if config != nil && config.CompilerOptions().DisableSolutionSearching.IsTrue() { - return false - } - if ancestorConfigName := f.getAncestorConfigFileName(info, configFileName, loadKind); ancestorConfigName != "" { - return f.tryFindDefaultConfiguredProjectForScriptInfo(info, ancestorConfigName, loadKind, result) - } - return false -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectForScriptInfo( - info *ScriptInfo, - configFileName string, - loadKind projectLoadKind, - result *openScriptInfoProjectResult, -) bool { - // Lookup from parsedConfig if available - configFilePath := f.service.toPath(configFileName) - config := f.findOrAcquireConfig(info, configFileName, configFilePath, loadKind) - if config != nil { - if config.CompilerOptions().Composite == core.TSTrue { - if f.isDefaultConfigForScriptInfo(info, configFileName, configFilePath, config, loadKind, result) { - return true - } - } else if len(config.FileNames()) > 0 { - project := f.findOrCreateProject(configFileName, configFilePath, loadKind) - if f.isDefaultProject(info, project, loadKind, result) { - return true - } - } - // Lookup in references - if f.tryFindDefaultConfiguredProjectFromReferences(info, config, loadKind, result) { - return true - } - } - // Lookup in ancestor projects - if f.tryFindDefaultConfiguredProjectFromAncestor(info, configFileName, config, loadKind, result) { - return true - } - return false -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectForOpenScriptInfo( - info *ScriptInfo, - loadKind projectLoadKind, -) *openScriptInfoProjectResult { - if configFileName := f.getConfigFileNameForFile(info, loadKind); configFileName != "" { - var result openScriptInfoProjectResult - f.tryFindDefaultConfiguredProjectForScriptInfo(info, configFileName, loadKind, &result) - if result.project == nil && result.fallbackDefault != nil { - result.setProject(result.fallbackDefault) - } - return &result - } - return nil -} - -func (f *defaultProjectFinder) tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo( - info *ScriptInfo, - projectLoadKind projectLoadKind, -) *openScriptInfoProjectResult { - result := f.tryFindDefaultConfiguredProjectForOpenScriptInfo(info, projectLoadKind) - if result != nil && result.project != nil { - // !!! sheetal todo this later - // // Create ancestor tree for findAllRefs (dont load them right away) - // forEachAncestorProjectLoad( - // info, - // tsconfigProject!, - // ancestor => { - // seenProjects.set(ancestor.project, kind); - // }, - // kind, - // `Creating project possibly referencing default composite project ${defaultProject.getProjectName()} of open file ${info.fileName}`, - // allowDeferredClosed, - // reloadedProjects, - // /*searchOnlyPotentialSolution*/ true, - // delayReloadedConfiguredProjects, - // ); - } - return result -} - -func (f *defaultProjectFinder) findDefaultConfiguredProject(scriptInfo *ScriptInfo) *Project { - if f.service.isOpenFile(scriptInfo) { - result := f.tryFindDefaultConfiguredProjectForOpenScriptInfo(scriptInfo, projectLoadKindFind) - if result != nil && result.project != nil && !result.project.deferredClose { - return result.project - } - } - return nil -} - -type openScriptInfoProjectResult struct { - projectMu sync.RWMutex - project *Project - fallbackDefaultMu sync.RWMutex - fallbackDefault *Project // use this if we cant find actual project - seenProjects collections.SyncMap[*Project, projectLoadKind] - seenConfigs collections.SyncMap[tspath.Path, projectLoadKind] -} - -func (r *openScriptInfoProjectResult) addSeenProject(project *Project, loadKind projectLoadKind) bool { - if kind, loaded := r.seenProjects.LoadOrStore(project, loadKind); loaded { - if kind >= loadKind { - return false - } - r.seenProjects.Store(project, loadKind) - } - return true -} - -func (r *openScriptInfoProjectResult) addSeenConfig(configPath tspath.Path, loadKind projectLoadKind) bool { - if kind, loaded := r.seenConfigs.LoadOrStore(configPath, loadKind); loaded { - if kind >= loadKind { - return false - } - r.seenConfigs.Store(configPath, loadKind) - } - return true -} - -func (r *openScriptInfoProjectResult) isDone() bool { - r.projectMu.RLock() - defer r.projectMu.RUnlock() - return r.project != nil -} - -func (r *openScriptInfoProjectResult) setProject(project *Project) { - r.projectMu.Lock() - defer r.projectMu.Unlock() - if r.project == nil { - r.project = project - } -} - -func (r *openScriptInfoProjectResult) hasFallbackDefault() bool { - r.fallbackDefaultMu.RLock() - defer r.fallbackDefaultMu.RUnlock() - return r.fallbackDefault != nil -} - -func (r *openScriptInfoProjectResult) setFallbackDefault(project *Project) { - r.fallbackDefaultMu.Lock() - defer r.fallbackDefaultMu.Unlock() - if r.fallbackDefault == nil { - r.fallbackDefault = project - } -} diff --git a/internal/project/defaultprojectfinder_test.go b/internal/project/defaultprojectfinder_test.go deleted file mode 100644 index 0473ccdc8f..0000000000 --- a/internal/project/defaultprojectfinder_test.go +++ /dev/null @@ -1,307 +0,0 @@ -package project_test - -import ( - "fmt" - "strings" - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/project" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "github.com/microsoft/typescript-go/internal/tspath" - "gotest.tools/v3/assert" -) - -func TestDefaultProjectFinder(t *testing.T) { - t.Parallel() - - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - t.Run("when project found is solution referencing default project directly", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, "", nil) - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - srcProject := service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) - assert.Assert(t, srcProject != nil) - _, project := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, project, srcProject) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - }) - - t.Run("when project found is solution referencing default project indirectly", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json", "./tsconfig-indirect2.json"}, "", nil) - applyIndirectProjectFiles(files, 1, "") - applyIndirectProjectFiles(files, 2, "") - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - srcProject := service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) - assert.Assert(t, srcProject != nil) - _, project := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, project, srcProject) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-indirect1.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-indirect2.json"), false) - }) - - t.Run("when project found is solution with disableReferencedProjectLoad referencing default project directly", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, `"disableReferencedProjectLoad": true`, nil) - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) == nil) - // Should not create referenced project - _, proj := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, proj.Kind(), project.KindInferred) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - }) - - t.Run("when project found is solution referencing default project indirectly through with disableReferencedProjectLoad", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json"}, "", nil) - applyIndirectProjectFiles(files, 1, `"disableReferencedProjectLoad": true`) - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) == nil) - // Inferred project because no default is found - _, proj := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, proj.Kind(), project.KindInferred) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-indirect1.json"), false) - }) - - t.Run("when project found is solution referencing default project indirectly through with disableReferencedProjectLoad in one but without it in another", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json", "./tsconfig-indirect2.json"}, "", nil) - applyIndirectProjectFiles(files, 1, `"disableReferencedProjectLoad": true`) - applyIndirectProjectFiles(files, 2, "") - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - srcProject := service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) - assert.Assert(t, srcProject != nil) - // Default project is found through one indirect - _, proj := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, proj, srcProject) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-indirect1.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-indirect2.json"), false) - }) - - t.Run("when project found is project with own files referencing the file from referenced project", func(t *testing.T) { - t.Parallel() - files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, "", []string{"./own/main.ts"}) - files["/user/username/projects/myproject/own/main.ts"] = ` - import { foo } from '../src/main'; - foo; - export function bar() {} - ` - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/src/main.ts", files["/user/username/projects/myproject/src/main.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - srcProject := service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) - assert.Assert(t, srcProject != nil) - assert.Assert(t, service.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig.json")) != nil) - _, project := service.EnsureDefaultProjectForFile("/user/username/projects/myproject/src/main.ts") - assert.Equal(t, project, srcProject) - service.CloseFile("/user/username/projects/myproject/src/main.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/user/username/projects/myproject/tsconfig-src.json"), false) - }) - - t.Run("when file is not part of first config tree found, looks into ancestor folder and its references to find default project", func(t *testing.T) { - t.Parallel() - files := map[string]any{ - "/home/src/projects/project/app/Component-demos.ts": ` - import * as helpers from 'demos/helpers'; - export const demo = () => { - helpers; - } - `, - "/home/src/projects/project/app/Component.ts": `export const Component = () => {}`, - "/home/src/projects/project/app/tsconfig.json": `{ - "compilerOptions": { - "composite": true, - "outDir": "../app-dist/", - }, - "include": ["**/*"], - "exclude": ["**/*-demos.*"], - }`, - "/home/src/projects/project/demos/helpers.ts": "export const foo = 1;", - "/home/src/projects/project/demos/tsconfig.json": `{ - "compilerOptions": { - "composite": true, - "rootDir": "../", - "outDir": "../demos-dist/", - "paths": { - "demos/*": ["./*"], - }, - }, - "include": [ - "**/*", - "../app/**/*-demos.*", - ], - }`, - "/home/src/projects/project/tsconfig.json": `{ - "compilerOptions": { - "outDir": "./dist/", - }, - "references": [ - { "path": "./demos/tsconfig.json" }, - { "path": "./app/tsconfig.json" }, - ], - "files": [] - }`, - } - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/home/src/projects/project/app/Component-demos.ts", files["/home/src/projects/project/app/Component-demos.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - demoProject := service.ConfiguredProject(tspath.Path("/home/src/projects/project/demos/tsconfig.json")) - assert.Assert(t, demoProject != nil) - configFileExists(t, service, tspath.Path("/home/src/projects/project/app/tsconfig.json"), true) - configFileExists(t, service, tspath.Path("/home/src/projects/project/demos/tsconfig.json"), true) - configFileExists(t, service, tspath.Path("/home/src/projects/project/tsconfig.json"), true) - _, project := service.EnsureDefaultProjectForFile("/home/src/projects/project/app/Component-demos.ts") - assert.Equal(t, project, demoProject) - service.CloseFile("/home/src/projects/project/app/Component-demos.ts") - service.OpenFile("/user/username/workspaces/dummy/dummy.ts", "const x = 1;", core.ScriptKindTS, "/user/username/workspaces/dummy") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("/user/username/workspaces/dummy")) != nil) - configFileExists(t, service, tspath.Path("/home/src/projects/project/app/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/home/src/projects/project/demos/tsconfig.json"), false) - configFileExists(t, service, tspath.Path("/home/src/projects/project/tsconfig.json"), false) - }) - - t.Run("when dts file is next to ts file and included as root in referenced project", func(t *testing.T) { - t.Parallel() - files := map[string]any{ - "/home/src/projects/project/src/index.d.ts": ` - declare global { - interface Window { - electron: ElectronAPI - api: unknown - } - } - `, - "/home/src/projects/project/src/index.ts": `const api = {}`, - "/home/src/projects/project/tsconfig.json": `{ - "include": [ - "src/*.d.ts", - ], - "references": [{ "path": "./tsconfig.node.json" }], - }`, - "/home/src/projects/project/tsconfig.node.json": `{ - include: ["src/**/*"], - compilerOptions: { - composite: true, - }, - }`, - } - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/home/src/projects/project/src/index.d.ts", files["/home/src/projects/project/src/index.d.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - assert.Assert(t, service.ConfiguredProject(tspath.Path("/home/src/projects/project/tsconfig.json")) != nil) - _, proj := service.EnsureDefaultProjectForFile("/home/src/projects/project/src/index.d.ts") - assert.Equal(t, proj.Kind(), project.KindInferred) - }) -} - -func filesForSolutionConfigFile(solutionRefs []string, compilerOptions string, ownFiles []string) map[string]any { - var compilerOptionsStr string - if compilerOptions != "" { - compilerOptionsStr = fmt.Sprintf(`"compilerOptions": { - %s - },`, compilerOptions) - } - var ownFilesStr string - if len(ownFiles) > 0 { - ownFilesStr = strings.Join(ownFiles, ",") - } - files := map[string]any{ - "/user/username/projects/myproject/tsconfig.json": fmt.Sprintf(`{ - %s - "files": [%s], - "references": [ - %s - ] - }`, compilerOptionsStr, ownFilesStr, strings.Join(core.Map(solutionRefs, func(ref string) string { - return fmt.Sprintf(`{ "path": "%s" }`, ref) - }), ",")), - "/user/username/projects/myproject/tsconfig-src.json": `{ - "compilerOptions": { - "composite": true, - "outDir": "./target", - }, - "include": ["./src/**/*"] - }`, - "/user/username/projects/myproject/src/main.ts": ` - import { foo } from './src/helpers/functions'; - export { foo };`, - "/user/username/projects/myproject/src/helpers/functions.ts": `export const foo = 1;`, - } - return files -} - -func applyIndirectProjectFiles(files map[string]any, projectIndex int, compilerOptions string) { - for k, v := range filesForIndirectProject(projectIndex, compilerOptions) { - files[k] = v - } -} - -func filesForIndirectProject(projectIndex int, compilerOptions string) map[string]any { - files := map[string]any{ - fmt.Sprintf("/user/username/projects/myproject/tsconfig-indirect%d.json", projectIndex): fmt.Sprintf(`{ - "compilerOptions": { - "composite": true, - "outDir": "./target/", - %s - }, - "files": [ - "./indirect%d/main.ts" - ], - "references": [ - { - "path": "./tsconfig-src.json" - } - ] - }`, compilerOptions, projectIndex), - fmt.Sprintf("/user/username/projects/myproject/indirect%d/main.ts", projectIndex): `export const indirect = 1;`, - } - return files -} diff --git a/internal/project/dirty/box.go b/internal/project/dirty/box.go new file mode 100644 index 0000000000..29a991bddc --- /dev/null +++ b/internal/project/dirty/box.go @@ -0,0 +1,62 @@ +package dirty + +type Box[T Cloneable[T]] struct { + original T + value T + dirty bool + delete bool +} + +func NewBox[T Cloneable[T]](original T) *Box[T] { + return &Box[T]{original: original, value: original} +} + +func (b *Box[T]) Value() T { + if b.delete { + var zero T + return zero + } + return b.value +} + +func (b *Box[T]) Original() T { + return b.original +} + +func (b *Box[T]) Dirty() bool { + return b.dirty +} + +func (b *Box[T]) Set(value T) { + b.value = value + b.delete = false + b.dirty = true +} + +func (b *Box[T]) Change(apply func(T)) { + if !b.dirty { + b.value = b.value.Clone() + b.dirty = true + } + apply(b.value) +} + +func (b *Box[T]) ChangeIf(cond func(T) bool, apply func(T)) bool { + if cond(b.value) { + b.Change(apply) + return true + } + return false +} + +func (b *Box[T]) Delete() { + b.delete = true +} + +func (b *Box[T]) Locked(fn func(Value[T])) { + fn(b) +} + +func (b *Box[T]) Finalize() (T, bool) { + return b.Value(), b.dirty || b.delete +} diff --git a/internal/project/dirty/entry.go b/internal/project/dirty/entry.go new file mode 100644 index 0000000000..44ab67efee --- /dev/null +++ b/internal/project/dirty/entry.go @@ -0,0 +1,29 @@ +package dirty + +type mapEntry[K comparable, V any] struct { + key K + original V + value V + dirty bool + delete bool +} + +func (e *mapEntry[K, V]) Key() K { + return e.key +} + +func (e *mapEntry[K, V]) Original() V { + return e.original +} + +func (e *mapEntry[K, V]) Value() V { + if e.delete { + var zero V + return zero + } + return e.value +} + +func (e *mapEntry[K, V]) Dirty() bool { + return e.dirty +} diff --git a/internal/project/dirty/interfaces.go b/internal/project/dirty/interfaces.go new file mode 100644 index 0000000000..c647eeadae --- /dev/null +++ b/internal/project/dirty/interfaces.go @@ -0,0 +1,15 @@ +package dirty + +type Cloneable[T any] interface { + Clone() T +} + +type Value[T any] interface { + Value() T + Original() T + Dirty() bool + Change(apply func(T)) + ChangeIf(cond func(T) bool, apply func(T)) bool + Delete() + Locked(fn func(Value[T])) +} diff --git a/internal/project/dirty/map.go b/internal/project/dirty/map.go new file mode 100644 index 0000000000..c25a19dd75 --- /dev/null +++ b/internal/project/dirty/map.go @@ -0,0 +1,148 @@ +package dirty + +import "maps" + +type MapEntry[K comparable, V Cloneable[V]] struct { + m *Map[K, V] + mapEntry[K, V] +} + +func (e *MapEntry[K, V]) Change(apply func(V)) { + if e.delete { + panic("tried to change a deleted entry") + } + if !e.dirty { + e.value = e.value.Clone() + e.dirty = true + e.m.dirty[e.key] = e + } + apply(e.value) +} + +func (e *MapEntry[K, V]) ChangeIf(cond func(V) bool, apply func(V)) bool { + if cond(e.Value()) { + e.Change(apply) + return true + } + return false +} + +func (e *MapEntry[K, V]) Delete() { + if !e.dirty { + e.m.dirty[e.key] = e + } + e.delete = true +} + +func (e *MapEntry[K, V]) Locked(fn func(Value[V])) { + fn(e) +} + +type Map[K comparable, V Cloneable[V]] struct { + base map[K]V + dirty map[K]*MapEntry[K, V] +} + +func NewMap[K comparable, V Cloneable[V]](base map[K]V) *Map[K, V] { + return &Map[K, V]{ + base: base, + dirty: make(map[K]*MapEntry[K, V]), + } +} + +func (m *Map[K, V]) Get(key K) (*MapEntry[K, V], bool) { + if entry, ok := m.dirty[key]; ok { + if entry.delete { + return nil, false + } + return entry, true + } + value, ok := m.base[key] + if !ok { + return nil, false + } + return &MapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + original: value, + value: value, + dirty: false, + }, + }, true +} + +// Add sets a new entry in the dirty map without checking if it exists +// in the base map. The entry added is considered dirty, so it should +// be a fresh value, mutable until finalized (i.e., it will not be cloned +// before changing if a change is made). If modifying an entry that may +// exist in the base map, use `Change` instead. +func (m *Map[K, V]) Add(key K, value V) { + m.dirty[key] = &MapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + value: value, + dirty: true, + }, + } +} + +// !!! Decide whether this, entry.Change(), or both should exist +func (m *Map[K, V]) Change(key K, apply func(V)) { + if entry, ok := m.Get(key); ok { + entry.Change(apply) + } else { + panic("tried to change a non-existent entry") + } +} + +func (m *Map[K, V]) Delete(key K) { + if entry, ok := m.Get(key); ok { + entry.Delete() + } else { + panic("tried to delete a non-existent entry") + } +} + +func (m *Map[K, V]) Range(fn func(*MapEntry[K, V]) bool) { + seenInDirty := make(map[K]struct{}) + for _, entry := range m.dirty { + seenInDirty[entry.key] = struct{}{} + if !entry.delete && !fn(entry) { + break + } + } + for key, value := range m.base { + if _, ok := seenInDirty[key]; ok { + continue // already processed in dirty entries + } + if !fn(&MapEntry[K, V]{m: m, mapEntry: mapEntry[K, V]{ + key: key, + original: value, + value: value, + dirty: false, + }}) { + break + } + } +} + +func (m *Map[K, V]) Finalize() (result map[K]V, changed bool) { + if len(m.dirty) == 0 { + return m.base, false // no changes, return base map + } + if m.base == nil { + result = make(map[K]V, len(m.dirty)) + } else { + result = maps.Clone(m.base) + } + for key, entry := range m.dirty { + if entry.delete { + delete(result, key) + } else { + result[key] = entry.value + } + } + return result, true +} diff --git a/internal/project/dirty/syncmap.go b/internal/project/dirty/syncmap.go new file mode 100644 index 0000000000..3a69d2deef --- /dev/null +++ b/internal/project/dirty/syncmap.go @@ -0,0 +1,335 @@ +package dirty + +import ( + "maps" + "sync" + + "github.com/microsoft/typescript-go/internal/collections" +) + +type lockedEntry[K comparable, V Cloneable[V]] struct { + e *SyncMapEntry[K, V] +} + +func (e *lockedEntry[K, V]) Value() V { + return e.e.valueLocked() +} + +func (e *lockedEntry[K, V]) Original() V { + return e.e.original +} + +func (e *lockedEntry[K, V]) Dirty() bool { + return e.e.dirty +} + +func (e *lockedEntry[K, V]) Change(apply func(V)) { + e.e.changeLocked(apply) +} + +func (e *lockedEntry[K, V]) ChangeIf(cond func(V) bool, apply func(V)) bool { + if cond(e.e.valueLocked()) { + e.e.changeLocked(apply) + return true + } + return false +} + +func (e *lockedEntry[K, V]) Delete() { + e.e.deleteLocked() +} + +func (e *lockedEntry[K, V]) Locked(fn func(Value[V])) { + fn(e) +} + +type SyncMapEntry[K comparable, V Cloneable[V]] struct { + m *SyncMap[K, V] + mu sync.Mutex + mapEntry[K, V] + // proxyFor is set when this entry loses a race to become the dirty entry + // for a value. Since two goroutines hold a reference to two entries that + // may try to mutate the same underlying value, all mutations are routed + // through the one that actually exists in the dirty map. + proxyFor *SyncMapEntry[K, V] +} + +func (e *SyncMapEntry[K, V]) Value() V { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + return e.proxyFor.Value() + } + return e.valueLocked() +} + +func (e *SyncMapEntry[K, V]) valueLocked() V { + if e.delete { + var zero V + return zero + } + return e.value +} + +func (e *SyncMapEntry[K, V]) Dirty() bool { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + return e.proxyFor.Dirty() + } + return e.dirty +} + +func (e *SyncMapEntry[K, V]) Locked(fn func(Value[V])) { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + e.proxyFor.Locked(fn) + return + } + fn(&lockedEntry[K, V]{e: e}) +} + +func (e *SyncMapEntry[K, V]) Change(apply func(V)) { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + e.proxyFor.Change(apply) + return + } + e.changeLocked(apply) +} + +func (e *SyncMapEntry[K, V]) changeLocked(apply func(V)) { + if e.dirty { + apply(e.value) + return + } + + entry, loaded := e.m.dirty.LoadOrStore(e.key, e) + if loaded { + entry.mu.Lock() + defer entry.mu.Unlock() + } + if !entry.dirty { + entry.value = entry.value.Clone() + entry.dirty = true + } + if loaded { + e.proxyFor = entry + e.value = entry.value + e.dirty = true + e.delete = entry.delete + } + apply(entry.value) +} + +func (e *SyncMapEntry[K, V]) ChangeIf(cond func(V) bool, apply func(V)) bool { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + return e.proxyFor.ChangeIf(cond, apply) + } + + if cond(e.value) { + e.changeLocked(apply) + return true + } + return false +} + +func (e *SyncMapEntry[K, V]) Delete() { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + e.proxyFor.Delete() + return + } + + if e.dirty { + e.delete = true + return + } + entry, loaded := e.m.dirty.LoadOrStore(e.key, e) + if loaded { + entry.mu.Lock() + defer entry.mu.Unlock() + e.delete = true + } else { + entry.delete = true + } +} + +func (e *SyncMapEntry[K, V]) deleteLocked() { + if e.dirty { + e.delete = true + return + } + entry, loaded := e.m.dirty.LoadOrStore(e.key, e) + if loaded { + entry.mu.Lock() + defer entry.mu.Unlock() + e.proxyFor = entry + e.value = entry.value + e.delete = true + e.dirty = entry.dirty + } + entry.delete = true +} + +func (e *SyncMapEntry[K, V]) DeleteIf(cond func(V) bool) { + e.mu.Lock() + defer e.mu.Unlock() + if e.proxyFor != nil { + e.proxyFor.DeleteIf(cond) + return + } + if cond(e.value) { + e.deleteLocked() + } +} + +type SyncMap[K comparable, V Cloneable[V]] struct { + base map[K]V + dirty collections.SyncMap[K, *SyncMapEntry[K, V]] + finalizeValue func(dirty V, original V) V +} + +func NewSyncMap[K comparable, V Cloneable[V]](base map[K]V, finalizeValue func(dirty V, original V) V) *SyncMap[K, V] { + return &SyncMap[K, V]{ + base: base, + dirty: collections.SyncMap[K, *SyncMapEntry[K, V]]{}, + finalizeValue: finalizeValue, + } +} + +func (m *SyncMap[K, V]) Load(key K) (*SyncMapEntry[K, V], bool) { + if entry, ok := m.dirty.Load(key); ok { + if entry.delete { + return nil, false + } + return entry, true + } + if val, ok := m.base[key]; ok { + return &SyncMapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + original: val, + value: val, + dirty: false, + delete: false, + }, + }, true + } + return nil, false +} + +func (m *SyncMap[K, V]) LoadOrStore(key K, value V) (*SyncMapEntry[K, V], bool) { + // Check for existence in the base map first so the sync map access is atomic. + if baseValue, ok := m.base[key]; ok { + if dirty, ok := m.dirty.Load(key); ok { + dirty.mu.Lock() + defer dirty.mu.Unlock() + if dirty.delete { + return nil, false + } + return dirty, true + } + return &SyncMapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + original: baseValue, + value: baseValue, + dirty: false, + delete: false, + }, + }, true + } + entry, loaded := m.dirty.LoadOrStore(key, &SyncMapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + value: value, + dirty: true, + }, + }) + if loaded { + entry.mu.Lock() + defer entry.mu.Unlock() + if entry.delete { + return nil, false + } + } + return entry, loaded +} + +func (m *SyncMap[K, V]) Delete(key K) { + entry, loaded := m.dirty.LoadOrStore(key, &SyncMapEntry[K, V]{ + m: m, + mapEntry: mapEntry[K, V]{ + key: key, + original: m.base[key], + delete: true, + }, + }) + if loaded { + entry.Delete() + } +} + +func (m *SyncMap[K, V]) Range(fn func(*SyncMapEntry[K, V]) bool) { + seenInDirty := make(map[K]struct{}) + m.dirty.Range(func(key K, entry *SyncMapEntry[K, V]) bool { + seenInDirty[key] = struct{}{} + if !entry.delete && !fn(entry) { + return false + } + return true + }) + for key, value := range m.base { + if _, ok := seenInDirty[key]; ok { + continue // already processed in dirty entries + } + if !fn(&SyncMapEntry[K, V]{m: m, mapEntry: mapEntry[K, V]{ + key: key, + original: value, + value: value, + dirty: false, + }}) { + break + } + } +} + +func (m *SyncMap[K, V]) Finalize() (map[K]V, bool) { + var changed bool + result := m.base + ensureCloned := func() { + if !changed { + if m.base == nil { + result = make(map[K]V) + } else { + result = maps.Clone(m.base) + } + changed = true + } + } + + m.dirty.Range(func(key K, entry *SyncMapEntry[K, V]) bool { + if entry.delete { + ensureCloned() + delete(result, key) + } else if entry.dirty { + ensureCloned() + if m.finalizeValue != nil { + result[key] = m.finalizeValue(entry.value, entry.original) + } else { + result[key] = entry.value + } + } + return true + }) + return result, changed +} diff --git a/internal/project/dirty/syncmap_test.go b/internal/project/dirty/syncmap_test.go new file mode 100644 index 0000000000..720f92b22b --- /dev/null +++ b/internal/project/dirty/syncmap_test.go @@ -0,0 +1,245 @@ +package dirty + +import ( + "sync" + "testing" + + "gotest.tools/v3/assert" +) + +// testValue is a simple cloneable type for testing +type testValue struct { + data string +} + +func (v *testValue) Clone() *testValue { + return &testValue{data: v.data} +} + +func TestSyncMapProxyFor(t *testing.T) { + t.Parallel() + + t.Run("proxy for race condition", func(t *testing.T) { + t.Parallel() + + // Create a sync map with a base value + base := map[string]*testValue{ + "key1": {data: "original"}, + } + syncMap := NewSyncMap(base, nil) + + // Load the same entry from multiple goroutines to simulate race condition + var entry1, entry2 *SyncMapEntry[string, *testValue] + var wg sync.WaitGroup + wg.Add(2) + + // First goroutine loads the entry + go func() { + defer wg.Done() + var ok bool + entry1, ok = syncMap.Load("key1") + assert.Assert(t, ok, "entry1 should be loaded") + }() + + // Second goroutine loads the same entry + go func() { + defer wg.Done() + var ok bool + entry2, ok = syncMap.Load("key1") + assert.Assert(t, ok, "entry2 should be loaded") + }() + + wg.Wait() + + // Both entries should exist and have the same initial value + assert.Equal(t, "original", entry1.Value().data) + assert.Equal(t, "original", entry2.Value().data) + assert.Equal(t, false, entry1.Dirty()) + assert.Equal(t, false, entry2.Dirty()) + + // Now try to change both entries concurrently to trigger the proxy mechanism. + // (This change doesn't actually have to be concurrent to test the proxy behavior, + // but might exercise concurrency safety in -race mode.) + var changeWg sync.WaitGroup + changeWg.Add(2) + + go func() { + defer changeWg.Done() + entry1.Change(func(v *testValue) { + v.data = "changed_by_entry1" + }) + }() + + go func() { + defer changeWg.Done() + entry2.Change(func(v *testValue) { + v.data = "changed_by_entry2" + }) + }() + + changeWg.Wait() + + // After the race, one entry should have proxyFor set and both should reflect the same final state + // The exact final value depends on which goroutine wins the race, but both entries should be consistent + finalValue1 := entry1.Value().data + finalValue2 := entry2.Value().data + assert.Equal(t, finalValue1, finalValue2, "both entries should have the same final value") + + // Both entries should be marked as dirty + assert.Equal(t, true, entry1.Dirty()) + assert.Equal(t, true, entry2.Dirty()) + + // At least one entry should have proxyFor set (the one that lost the race) + hasProxy := (entry1.proxyFor != nil) || (entry2.proxyFor != nil) + assert.Assert(t, hasProxy, "at least one entry should have proxyFor set") + + // If entry1 has a proxy, it should point to entry2, and vice versa + if entry1.proxyFor != nil { + assert.Equal(t, entry2, entry1.proxyFor, "entry1 should proxy to entry2") + } + if entry2.proxyFor != nil { + assert.Equal(t, entry1, entry2.proxyFor, "entry2 should proxy to entry1") + } + }) + + t.Run("proxy operations delegation", func(t *testing.T) { + t.Parallel() + + base := map[string]*testValue{ + "key1": {data: "original"}, + } + syncMap := NewSyncMap(base, nil) + + // Load two entries for the same key + entry1, ok1 := syncMap.Load("key1") + assert.Assert(t, ok1) + entry2, ok2 := syncMap.Load("key1") + assert.Assert(t, ok2) + + // Force one to become a proxy by making them both dirty in sequence + entry1.Change(func(v *testValue) { + v.data = "changed_by_entry1" + }) + entry2.Change(func(v *testValue) { + v.data = "changed_by_entry2" + }) + + // Determine which is the proxy and which is the target + var proxy, target *SyncMapEntry[string, *testValue] + if entry1.proxyFor != nil { + proxy = entry1 + target = entry2 + } else { + proxy = entry2 + target = entry1 + } + + // Test that proxy operations are delegated to the target + // Change through proxy should affect target + proxy.Change(func(v *testValue) { + v.data = "changed_through_proxy" + }) + assert.Equal(t, "changed_through_proxy", target.Value().data) + assert.Equal(t, "changed_through_proxy", proxy.Value().data) + + // ChangeIf through proxy should work + changed := proxy.ChangeIf( + func(v *testValue) bool { return v.data == "changed_through_proxy" }, + func(v *testValue) { v.data = "conditional_change" }, + ) + assert.Assert(t, changed) + assert.Equal(t, "conditional_change", target.Value().data) + assert.Equal(t, "conditional_change", proxy.Value().data) + + // Dirty status should be consistent + assert.Equal(t, target.Dirty(), proxy.Dirty()) + + // Locked operations should work through proxy + proxy.Locked(func(v Value[*testValue]) { + v.Change(func(val *testValue) { + val.data = "locked_change" + }) + }) + assert.Equal(t, "locked_change", target.Value().data) + assert.Equal(t, "locked_change", proxy.Value().data) + }) + + t.Run("proxy delete operations", func(t *testing.T) { + t.Parallel() + + base := map[string]*testValue{ + "key1": {data: "original"}, + } + syncMap := NewSyncMap(base, nil) + + // Load two entries and make one a proxy + entry1, _ := syncMap.Load("key1") + entry2, _ := syncMap.Load("key1") + + entry1.Change(func(v *testValue) { v.data = "modified" }) + entry2.Change(func(v *testValue) { v.data = "modified2" }) + + // Determine which is the proxy + var proxy *SyncMapEntry[string, *testValue] + if entry1.proxyFor != nil { + proxy = entry1 + } else { + proxy = entry2 + } + + // Delete through proxy should affect target + proxy.Delete() + + // Both should reflect the deletion + _, exists := syncMap.Load("key1") + assert.Equal(t, false, exists, "key should be deleted from sync map") + + // DeleteIf through proxy should work + base2 := map[string]*testValue{ + "key2": {data: "test"}, + } + syncMap2 := NewSyncMap(base2, nil) + + entry3, _ := syncMap2.Load("key2") + entry4, _ := syncMap2.Load("key2") + + entry3.Change(func(v *testValue) { v.data = "modified" }) + entry4.Change(func(v *testValue) { v.data = "modified2" }) + + var proxy2 *SyncMapEntry[string, *testValue] + if entry3.proxyFor != nil { + proxy2 = entry3 + } else { + proxy2 = entry4 + } + + proxy2.DeleteIf(func(v *testValue) bool { + return v.data == "modified2" || v.data == "modified" + }) + + _, exists2 := syncMap2.Load("key2") + assert.Equal(t, false, exists2, "key2 should be deleted conditionally") + }) + + t.Run("no proxy when no race", func(t *testing.T) { + t.Parallel() + + base := map[string]*testValue{ + "key1": {data: "original"}, + } + syncMap := NewSyncMap(base, nil) + + // Load and modify a single entry - no race condition + entry, ok := syncMap.Load("key1") + assert.Assert(t, ok) + + entry.Change(func(v *testValue) { + v.data = "changed" + }) + + // Should not have a proxy since there was no race + assert.Assert(t, entry.proxyFor == nil, "entry should not have proxyFor when no race occurs") + assert.Equal(t, true, entry.Dirty()) + assert.Equal(t, "changed", entry.Value().data) + }) +} diff --git a/internal/project/dirty/util.go b/internal/project/dirty/util.go new file mode 100644 index 0000000000..5c622aedda --- /dev/null +++ b/internal/project/dirty/util.go @@ -0,0 +1,18 @@ +package dirty + +import "maps" + +func CloneMapIfNil[K comparable, V any, T any](dirty *T, original *T, getMap func(*T) map[K]V) map[K]V { + dirtyMap := getMap(dirty) + if dirtyMap == nil { + if original == nil { + return make(map[K]V) + } + originalMap := getMap(original) + if originalMap == nil { + return make(map[K]V) + } + return maps.Clone(originalMap) + } + return dirtyMap +} diff --git a/internal/project/documentregistry.go b/internal/project/documentregistry.go deleted file mode 100644 index add8801864..0000000000 --- a/internal/project/documentregistry.go +++ /dev/null @@ -1,143 +0,0 @@ -package project - -import ( - "sync" - - "github.com/microsoft/typescript-go/internal/ast" - "github.com/microsoft/typescript-go/internal/collections" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/parser" - "github.com/microsoft/typescript-go/internal/tspath" -) - -type registryKey struct { - ast.SourceFileParseOptions - scriptKind core.ScriptKind -} - -func newRegistryKey(opts ast.SourceFileParseOptions, scriptKind core.ScriptKind) registryKey { - return registryKey{ - SourceFileParseOptions: opts, - scriptKind: scriptKind, - } -} - -type registryEntry struct { - sourceFile *ast.SourceFile - version int - refCount int - mu sync.Mutex -} - -type DocumentRegistryHooks struct { - OnReleaseDocument func(file *ast.SourceFile) -} - -// The document registry represents a store of SourceFile objects that can be shared between -// multiple LanguageService instances. -type DocumentRegistry struct { - Options tspath.ComparePathsOptions - Hooks DocumentRegistryHooks - documents collections.SyncMap[registryKey, *registryEntry] - parsedFileCache ParsedFileCache -} - -// AcquireDocument gets a SourceFile from the registry if it exists as the same version tracked -// by the ScriptInfo. If it does not exist, or is out of date, it creates a new SourceFile and -// stores it, tracking that the caller has referenced it. If an oldSourceFile is passed, the registry -// will decrement its reference count and remove it from the registry if the count reaches 0. -// (If the old file and new file have the same key, this results in a no-op to the ref count.) -// -// This code is greatly simplified compared to the old TS codebase because of the lack of -// incremental parsing. Previously, source files could be updated and reused by the same -// LanguageService instance over time, as well as across multiple instances. Here, we still -// reuse files across multiple LanguageServices, but we only reuse them across Program updates -// when the files haven't changed. -func (r *DocumentRegistry) AcquireDocument(scriptInfo *ScriptInfo, opts ast.SourceFileParseOptions, oldSourceFile *ast.SourceFile) *ast.SourceFile { - key := newRegistryKey(opts, scriptInfo.scriptKind) - document := r.getDocumentWorker(scriptInfo, key) - if oldSourceFile != nil { - r.releaseDocumentWithKey(key) - } - return document -} - -func (r *DocumentRegistry) ReleaseDocument(file *ast.SourceFile) { - key := newRegistryKey(file.ParseOptions(), file.ScriptKind) - r.releaseDocumentWithKey(key) -} - -func (r *DocumentRegistry) releaseDocumentWithKey(key registryKey) { - if entry, ok := r.documents.Load(key); ok { - entry.mu.Lock() - defer entry.mu.Unlock() - entry.refCount-- - if entry.refCount == 0 { - r.documents.Delete(key) - if r.Hooks.OnReleaseDocument != nil { - r.Hooks.OnReleaseDocument(entry.sourceFile) - } - } - } -} - -func (r *DocumentRegistry) getDocumentWorker(scriptInfo *ScriptInfo, key registryKey) *ast.SourceFile { - scriptInfoVersion := scriptInfo.Version() - scriptInfoText := scriptInfo.Text() - if entry, ok := r.documents.Load(key); ok { - // We have an entry for this file. However, it may be for a different version of - // the script snapshot. If so, update it appropriately. - if entry.version != scriptInfoVersion { - sourceFile := r.getParsedFile(key.SourceFileParseOptions, scriptInfoText, key.scriptKind) - entry.mu.Lock() - defer entry.mu.Unlock() - entry.sourceFile = sourceFile - entry.version = scriptInfoVersion - } - entry.refCount++ - return entry.sourceFile - } else { - // Have never seen this file with these settings. Create a new source file for it. - sourceFile := r.getParsedFile(key.SourceFileParseOptions, scriptInfoText, key.scriptKind) - entry, _ := r.documents.LoadOrStore(key, ®istryEntry{ - sourceFile: sourceFile, - refCount: 0, - version: scriptInfoVersion, - }) - entry.mu.Lock() - defer entry.mu.Unlock() - entry.refCount++ - return entry.sourceFile - } -} - -func (r *DocumentRegistry) getFileVersion(file *ast.SourceFile) int { - key := newRegistryKey(file.ParseOptions(), file.ScriptKind) - if entry, ok := r.documents.Load(key); ok && entry.sourceFile == file { - return entry.version - } - return -1 -} - -func (r *DocumentRegistry) getParsedFile(opts ast.SourceFileParseOptions, text string, scriptKind core.ScriptKind) *ast.SourceFile { - if r.parsedFileCache != nil { - if file := r.parsedFileCache.GetFile(opts, text, scriptKind); file != nil { - return file - } - } - file := parser.ParseSourceFile(opts, text, scriptKind) - if r.parsedFileCache != nil { - r.parsedFileCache.CacheFile(opts, text, scriptKind, file) - } - return file -} - -// size should only be used for testing. -func (r *DocumentRegistry) size() int { - return r.documents.Size() -} - -type ParsedFileCache interface { - GetFile(opts ast.SourceFileParseOptions, text string, scriptKind core.ScriptKind) *ast.SourceFile - CacheFile(opts ast.SourceFileParseOptions, text string, scriptKind core.ScriptKind, sourceFile *ast.SourceFile) -} diff --git a/internal/project/documentstore.go b/internal/project/documentstore.go deleted file mode 100644 index 59d4a7b457..0000000000 --- a/internal/project/documentstore.go +++ /dev/null @@ -1,164 +0,0 @@ -package project - -import ( - "sync" - - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/tspath" - "github.com/microsoft/typescript-go/internal/vfs" -) - -// DocumentStore manages ScriptInfo instances and the DocumentRegistry -// with thread-safe operations. -type DocumentStore struct { - documentRegistry *DocumentRegistry - - scriptInfosMu sync.RWMutex - scriptInfos map[tspath.Path]*ScriptInfo - - // Contains all the deleted script info's version information so that - // it does not reset when creating script info again - filenameToScriptInfoVersion map[tspath.Path]int - - realpathToScriptInfosMu sync.Mutex - realpathToScriptInfos map[tspath.Path]map[*ScriptInfo]struct{} -} - -// DocumentStoreOptions contains options for creating a DocumentStore -type DocumentStoreOptions struct { - ComparePathsOptions tspath.ComparePathsOptions - ParsedFileCache ParsedFileCache - Hooks DocumentRegistryHooks -} - -// NewDocumentStore creates a new DocumentStore with the given options -func NewDocumentStore(options DocumentStoreOptions) *DocumentStore { - return &DocumentStore{ - documentRegistry: &DocumentRegistry{ - Options: options.ComparePathsOptions, - parsedFileCache: options.ParsedFileCache, - Hooks: options.Hooks, - }, - scriptInfos: make(map[tspath.Path]*ScriptInfo), - filenameToScriptInfoVersion: make(map[tspath.Path]int), - realpathToScriptInfos: make(map[tspath.Path]map[*ScriptInfo]struct{}), - } -} - -// DocumentRegistry returns the document registry -func (ds *DocumentStore) DocumentRegistry() *DocumentRegistry { - return ds.documentRegistry -} - -// GetScriptInfoByPath returns the ScriptInfo for the given path, or nil if not found -func (ds *DocumentStore) GetScriptInfoByPath(path tspath.Path) *ScriptInfo { - ds.scriptInfosMu.RLock() - defer ds.scriptInfosMu.RUnlock() - if info, ok := ds.scriptInfos[path]; ok && !info.deferredDelete { - return info - } - return nil -} - -// GetOrCreateScriptInfo creates or returns an existing ScriptInfo for the given file -func (ds *DocumentStore) GetOrCreateScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind, fs vfs.FS) *ScriptInfo { - return ds.getOrCreateScriptInfoWorker(fileName, path, scriptKind, false, "", true, fs) -} - -// GetOrCreateOpenScriptInfo creates or returns an existing ScriptInfo for an opened file -func (ds *DocumentStore) GetOrCreateOpenScriptInfo(fileName string, path tspath.Path, fileContent string, scriptKind core.ScriptKind, fs vfs.FS) *ScriptInfo { - return ds.getOrCreateScriptInfoWorker(fileName, path, scriptKind, true, fileContent, true, fs) -} - -// getOrCreateScriptInfoWorker is the internal implementation for creating/getting ScriptInfo -func (ds *DocumentStore) getOrCreateScriptInfoWorker(fileName string, path tspath.Path, scriptKind core.ScriptKind, openedByClient bool, fileContent string, deferredDeleteOk bool, fs vfs.FS) *ScriptInfo { - ds.scriptInfosMu.RLock() - info, ok := ds.scriptInfos[path] - ds.scriptInfosMu.RUnlock() - - var fromDisk bool - if !ok { - if !openedByClient && !isDynamicFileName(fileName) { - if content, ok := fs.ReadFile(fileName); !ok { - return nil - } else { - fileContent = content - fromDisk = true - } - } - - info = NewScriptInfo(fileName, path, scriptKind, fs) - if fromDisk { - info.SetTextFromDisk(fileContent) - } - - ds.scriptInfosMu.Lock() - defer ds.scriptInfosMu.Unlock() - if prevVersion, ok := ds.filenameToScriptInfoVersion[path]; ok { - info.version = prevVersion + 1 - delete(ds.filenameToScriptInfoVersion, path) - } - ds.scriptInfos[path] = info - } else if info.deferredDelete { - if !openedByClient && !fs.FileExists(fileName) { - // If the file is not opened by client and the file does not exist on the disk, return - return core.IfElse(deferredDeleteOk, info, nil) - } - info.deferredDelete = false - } - - if openedByClient { - info.open(fileContent) - } - - return info -} - -// DeleteScriptInfo removes a ScriptInfo from the store -func (ds *DocumentStore) DeleteScriptInfo(info *ScriptInfo) { - ds.scriptInfosMu.Lock() - defer ds.scriptInfosMu.Unlock() - - ds.filenameToScriptInfoVersion[info.path] = info.version - delete(ds.scriptInfos, info.path) - - realpath := info.realpath - if realpath != "" { - ds.realpathToScriptInfosMu.Lock() - defer ds.realpathToScriptInfosMu.Unlock() - delete(ds.realpathToScriptInfos[realpath], info) - } -} - -// AddRealpathMapping adds a realpath mapping for a ScriptInfo -func (ds *DocumentStore) AddRealpathMapping(info *ScriptInfo) { - ds.realpathToScriptInfosMu.Lock() - defer ds.realpathToScriptInfosMu.Unlock() - if scriptInfos, ok := ds.realpathToScriptInfos[info.realpath]; ok { - scriptInfos[info] = struct{}{} - } else { - ds.realpathToScriptInfos[info.realpath] = map[*ScriptInfo]struct{}{ - info: {}, - } - } -} - -// SourceFileCount returns the number of documents in the registry -func (ds *DocumentStore) SourceFileCount() int { - return ds.documentRegistry.size() -} - -func (ds *DocumentStore) ScriptInfoCount() int { - return len(ds.scriptInfos) -} - -// ForEachScriptInfo calls the given function for each ScriptInfo in the store -func (ds *DocumentStore) ForEachScriptInfo(fn func(info *ScriptInfo)) { - ds.scriptInfosMu.RLock() - defer ds.scriptInfosMu.RUnlock() - for _, info := range ds.scriptInfos { - if !info.deferredDelete { - fn(info) - } - } -} diff --git a/internal/project/extendedconfigcache.go b/internal/project/extendedconfigcache.go new file mode 100644 index 0000000000..6c803f5ee9 --- /dev/null +++ b/internal/project/extendedconfigcache.go @@ -0,0 +1,75 @@ +package project + +import ( + "sync" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/zeebo/xxh3" +) + +type extendedConfigCache struct { + entries collections.SyncMap[tspath.Path, *extendedConfigCacheEntry] +} + +type extendedConfigCacheEntry struct { + mu sync.Mutex + entry *tsoptions.ExtendedConfigCacheEntry + hash xxh3.Uint128 + refCount int +} + +func (c *extendedConfigCache) Acquire(fh FileHandle, path tspath.Path, parse func() *tsoptions.ExtendedConfigCacheEntry) *tsoptions.ExtendedConfigCacheEntry { + entry, loaded := c.loadOrStoreNewLockedEntry(fh, path) + defer entry.mu.Unlock() + if !loaded || entry.hash != fh.Hash() { + // Reparse the config if the hash has changed, or parse for the first time. + entry.entry = parse() + entry.hash = fh.Hash() + } + return entry.entry +} + +func (c *extendedConfigCache) Ref(path tspath.Path) { + if entry, ok := c.entries.Load(path); ok { + entry.mu.Lock() + entry.refCount++ + entry.mu.Unlock() + } +} + +func (c *extendedConfigCache) Deref(path tspath.Path) { + if entry, ok := c.entries.Load(path); ok { + entry.mu.Lock() + entry.refCount-- + remove := entry.refCount <= 0 + entry.mu.Unlock() + if remove { + c.entries.Delete(path) + } + } +} + +func (c *extendedConfigCache) Has(path tspath.Path) bool { + _, ok := c.entries.Load(path) + return ok +} + +// loadOrStoreNewLockedEntry loads an existing entry or creates a new one. The returned +// entry's mutex is locked and its refCount is incremented (or initialized to 1 +// in the case of a new entry). +func (c *extendedConfigCache) loadOrStoreNewLockedEntry( + fh FileHandle, + path tspath.Path, +) (*extendedConfigCacheEntry, bool) { + entry := &extendedConfigCacheEntry{refCount: 1} + entry.mu.Lock() + existing, loaded := c.entries.LoadOrStore(path, entry) + if loaded { + existing.mu.Lock() + existing.refCount++ + return existing, true + } + return entry, false +} diff --git a/internal/project/filechange.go b/internal/project/filechange.go new file mode 100644 index 0000000000..afb5b24648 --- /dev/null +++ b/internal/project/filechange.go @@ -0,0 +1,47 @@ +package project + +import ( + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/zeebo/xxh3" +) + +type FileChangeKind int + +const ( + FileChangeKindOpen FileChangeKind = iota + FileChangeKindClose + FileChangeKindChange + FileChangeKindSave + FileChangeKindWatchCreate + FileChangeKindWatchChange + FileChangeKindWatchDelete +) + +type FileChange struct { + Kind FileChangeKind + URI lsproto.DocumentUri + Hash xxh3.Uint128 // Only set for Close + Version int32 // Only set for Open/Change + Content string // Only set for Open + LanguageKind lsproto.LanguageKind // Only set for Open + Changes []lsproto.TextDocumentContentChangePartialOrWholeDocument // Only set for Change +} + +type FileChangeSummary struct { + // Only one file can be opened at a time per request + Opened lsproto.DocumentUri + // Values are the content hashes of the overlays before closing. + Closed map[lsproto.DocumentUri]xxh3.Uint128 + Changed collections.Set[lsproto.DocumentUri] + // Only set when file watching is enabled + Created collections.Set[lsproto.DocumentUri] + // Only set when file watching is enabled + Deleted collections.Set[lsproto.DocumentUri] + + IncludesWatchChangesOnly bool +} + +func (f FileChangeSummary) IsEmpty() bool { + return f.Opened == "" && len(f.Closed) == 0 && f.Changed.Len() == 0 && f.Created.Len() == 0 && f.Deleted.Len() == 0 +} diff --git a/internal/project/host.go b/internal/project/host.go deleted file mode 100644 index 8fb664b1f1..0000000000 --- a/internal/project/host.go +++ /dev/null @@ -1,25 +0,0 @@ -package project - -import ( - "context" - - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/vfs" -) - -type WatcherHandle string - -type Client interface { - WatchFiles(ctx context.Context, watchers []*lsproto.FileSystemWatcher) (WatcherHandle, error) - UnwatchFiles(ctx context.Context, handle WatcherHandle) error - RefreshDiagnostics(ctx context.Context) error -} - -type ServiceHost interface { - FS() vfs.FS - DefaultLibraryPath() string - TypingsLocation() string - GetCurrentDirectory() string - - Client() Client -} diff --git a/internal/project/logger.go b/internal/project/logger.go deleted file mode 100644 index 4298b4c3c4..0000000000 --- a/internal/project/logger.go +++ /dev/null @@ -1,111 +0,0 @@ -package project - -import ( - "bufio" - "fmt" - "io" - "os" - "strings" - "sync" - "time" -) - -type LogLevel int - -const ( - LogLevelTerse LogLevel = iota - LogLevelNormal - LogLevelRequestTime - LogLevelVerbose -) - -type Logger struct { - mu sync.Mutex - outputs []*bufio.Writer - fileHandle *os.File - level LogLevel - seq int -} - -func NewLogger(outputs []io.Writer, file string, level LogLevel) *Logger { - var o []*bufio.Writer - for _, w := range outputs { - o = append(o, bufio.NewWriter(w)) - } - logger := &Logger{outputs: o, level: level} - logger.SetFile(file) - return logger -} - -func (l *Logger) SetFile(file string) { - l.mu.Lock() - defer l.mu.Unlock() - if l.fileHandle != nil { - oldWriter := l.outputs[len(l.outputs)-1] - l.outputs = l.outputs[:len(l.outputs)-1] - _ = oldWriter.Flush() - l.fileHandle.Close() - } - if file != "" { - f, err := os.OpenFile(file, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o666) - if err != nil { - panic(err) - } - l.fileHandle = f - l.outputs = append(l.outputs, bufio.NewWriter(f)) - } -} - -func (l *Logger) PerfTrace(s string) { - l.msg(s, "Perf") -} - -func (l *Logger) Info(s string) { - l.msg(s, "Info") -} - -func (l *Logger) Error(s string) { - l.msg(s, "Err") -} - -func (l *Logger) LoggingEnabled() bool { - return l != nil && len(l.outputs) > 0 -} - -func (l *Logger) HasLevel(level LogLevel) bool { - return l != nil && l.LoggingEnabled() && l.level >= level -} - -func (l *Logger) Close() { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - for _, output := range l.outputs { - _ = output.Flush() - } - if l.fileHandle != nil { - _ = l.fileHandle.Close() - } -} - -func (l *Logger) msg(s string, messageType string) { - if l == nil { - return - } - l.mu.Lock() - defer l.mu.Unlock() - for _, output := range l.outputs { - header := fmt.Sprintf("%s %d", messageType, l.seq) - output.WriteString(header) //nolint: errcheck - output.WriteString(strings.Repeat(" ", max(0, 10-len(header)))) //nolint: errcheck - output.WriteRune('[') //nolint: errcheck - output.WriteString(time.Now().Format("15:04:05.000")) //nolint: errcheck - output.WriteString("] ") //nolint: errcheck - output.WriteString(s) //nolint: errcheck - output.WriteRune('\n') //nolint: errcheck - output.Flush() - } - l.seq++ -} diff --git a/internal/project/logging/logcollector.go b/internal/project/logging/logcollector.go new file mode 100644 index 0000000000..b9a32ead6b --- /dev/null +++ b/internal/project/logging/logcollector.go @@ -0,0 +1,34 @@ +package logging + +import ( + "fmt" + "strings" + "time" +) + +type LogCollector interface { + fmt.Stringer + Logger +} + +type logCollector struct { + logger + builder strings.Builder +} + +func (lc *logCollector) String() string { + return lc.builder.String() +} + +func NewTestLogger() LogCollector { + var builder strings.Builder + return &logCollector{ + logger: logger{ + writer: &builder, + prefix: func() string { + return formatTime(time.Unix(1349085672, 0)) + }, + }, + builder: builder, + } +} diff --git a/internal/project/logging/logger.go b/internal/project/logging/logger.go new file mode 100644 index 0000000000..47465e26c6 --- /dev/null +++ b/internal/project/logging/logger.go @@ -0,0 +1,103 @@ +package logging + +import ( + "fmt" + "io" + "sync" + "time" +) + +type Logger interface { + // Log prints a line to the output writer with a header. + Log(msg ...any) + // Logf prints a formatted line to the output writer with a header. + Logf(format string, args ...any) + // Write prints the msg string to the output with no additional formatting, followed by a newline + Write(msg string) + // Verbose returns the logger instance if verbose logging is enabled, and otherwise returns nil. + // A nil logger created with `logging.NewLogger` is safe to call methods on. + Verbose() Logger + // IsVerbose returns true if verbose logging is enabled, and false otherwise. + IsVerbose() bool + // SetVerbose sets the verbose logging flag. + SetVerbose(verbose bool) +} + +var _ Logger = (*logger)(nil) + +type logger struct { + mu sync.Mutex + verbose bool + writer io.Writer + prefix func() string +} + +func (l *logger) Log(msg ...any) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + fmt.Fprintln(l.writer, l.prefix(), fmt.Sprint(msg...)) +} + +func (l *logger) Logf(format string, args ...any) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + fmt.Fprintf(l.writer, "%s %s\n", l.prefix(), fmt.Sprintf(format, args...)) +} + +func (l *logger) Write(msg string) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + fmt.Fprintln(l.writer, msg) +} + +func (l *logger) Verbose() Logger { + if l == nil { + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + if !l.verbose { + return nil + } + return l +} + +func (l *logger) IsVerbose() bool { + if l == nil { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + return l.verbose +} + +func (l *logger) SetVerbose(verbose bool) { + if l == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + l.verbose = verbose +} + +func NewLogger(output io.Writer) Logger { + return &logger{ + writer: output, + prefix: func() string { + return formatTime(time.Now()) + }, + } +} + +func formatTime(t time.Time) string { + return fmt.Sprintf("[%s]", t.Format("15:04:05.000")) +} diff --git a/internal/project/logging/logtree.go b/internal/project/logging/logtree.go new file mode 100644 index 0000000000..7786bacaa8 --- /dev/null +++ b/internal/project/logging/logtree.go @@ -0,0 +1,147 @@ +package logging + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "time" +) + +var seq atomic.Uint64 + +type logEntry struct { + seq uint64 + time time.Time + message string + child *LogTree +} + +func newLogEntry(child *LogTree, message string) *logEntry { + return &logEntry{ + seq: seq.Add(1), + time: time.Now(), + message: message, + child: child, + } +} + +var _ LogCollector = (*LogTree)(nil) + +type LogTree struct { + name string + mu sync.Mutex + logs []*logEntry + root *LogTree + level int + verbose bool + + // Only set on root + count atomic.Int32 + stringLength atomic.Int32 +} + +func NewLogTree(name string) *LogTree { + lc := &LogTree{ + name: name, + } + lc.root = lc + return lc +} + +func (c *LogTree) add(log *logEntry) { + // indent + header + message + newline + c.root.stringLength.Add(int32(c.level + 15 + len(log.message) + 1)) + c.root.count.Add(1) + c.mu.Lock() + defer c.mu.Unlock() + c.logs = append(c.logs, log) +} + +func (c *LogTree) Log(message ...any) { + if c == nil { + return + } + log := newLogEntry(nil, fmt.Sprint(message...)) + c.add(log) +} + +func (c *LogTree) Logf(format string, args ...any) { + if c == nil { + return + } + log := newLogEntry(nil, fmt.Sprintf(format, args...)) + c.add(log) +} + +func (c *LogTree) Write(msg string) { + if c == nil { + return + } + log := newLogEntry(nil, msg) + c.add(log) +} + +func (c *LogTree) IsVerbose() bool { + return c.verbose +} + +func (c *LogTree) SetVerbose(verbose bool) { + if c == nil { + return + } + c.verbose = verbose +} + +func (c *LogTree) Verbose() Logger { + if c == nil || !c.verbose { + return nil + } + return c +} + +func (c *LogTree) Embed(logs *LogTree) { + if c == nil { + return + } + count := logs.count.Load() + c.root.stringLength.Add(logs.stringLength.Load() + count*int32(c.level)) + c.root.count.Add(count) + log := newLogEntry(logs, logs.name) + c.add(log) +} + +func (c *LogTree) Fork(message string) *LogTree { + if c == nil { + return nil + } + child := &LogTree{level: c.level + 1, root: c.root, verbose: c.verbose} + log := newLogEntry(child, message) + c.add(log) + return child +} + +func (c *LogTree) String() string { + if c.root != c { + panic("can only call String on root LogTree") + } + var builder strings.Builder + header := fmt.Sprintf("======== %s ========\n", c.name) + builder.Grow(int(c.stringLength.Load()) + len(header)) + builder.WriteString(header) + c.writeLogsRecursive(&builder, "") + return builder.String() +} + +func (c *LogTree) writeLogsRecursive(builder *strings.Builder, indent string) { + for _, log := range c.logs { + builder.WriteString(indent) + builder.WriteString(formatTime(log.time)) + builder.WriteString(" ") + builder.WriteString(log.message) + builder.WriteString("\n") + if log.child != nil { + log.child.writeLogsRecursive(builder, indent+"\t") + } + } +} diff --git a/internal/project/logging/logtree_test.go b/internal/project/logging/logtree_test.go new file mode 100644 index 0000000000..bb7d5fa5ea --- /dev/null +++ b/internal/project/logging/logtree_test.go @@ -0,0 +1,20 @@ +package logging + +import ( + "testing" +) + +// Verify LogTree implements the expected interface +type testLogger interface { + Log(msg ...any) + Write(msg string) +} + +func TestLogTreeImplementsLogger(t *testing.T) { + t.Parallel() + var _ testLogger = &LogTree{} +} + +func TestLogTree(t *testing.T) { + t.Parallel() +} diff --git a/internal/project/namer.go b/internal/project/namer.go deleted file mode 100644 index a092f912b2..0000000000 --- a/internal/project/namer.go +++ /dev/null @@ -1,21 +0,0 @@ -package project - -import ( - "fmt" - "sync" -) - -type namer struct { - mu sync.Mutex - counters map[string]int -} - -func (n *namer) next(name string) string { - n.mu.Lock() - defer n.mu.Unlock() - if n.counters == nil { - n.counters = make(map[string]int) - } - n.counters[name]++ - return fmt.Sprintf("%s%d*", name, n.counters[name]) -} diff --git a/internal/project/overlayfs.go b/internal/project/overlayfs.go new file mode 100644 index 0000000000..1e3c2f70fb --- /dev/null +++ b/internal/project/overlayfs.go @@ -0,0 +1,366 @@ +package project + +import ( + "maps" + "sync" + + "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/tspath" + "github.com/microsoft/typescript-go/internal/vfs" + "github.com/zeebo/xxh3" +) + +type FileContent interface { + Content() string + Hash() xxh3.Uint128 +} + +type FileHandle interface { + FileContent + FileName() string + Version() int32 + MatchesDiskText() bool + IsOverlay() bool + LineMap() *ls.LineMap + Kind() core.ScriptKind +} + +type fileBase struct { + fileName string + content string + hash xxh3.Uint128 + + lineMapOnce sync.Once + lineMap *ls.LineMap +} + +func (f *fileBase) FileName() string { + return f.fileName +} + +func (f *fileBase) Hash() xxh3.Uint128 { + return f.hash +} + +func (f *fileBase) Content() string { + return f.content +} + +func (f *fileBase) LineMap() *ls.LineMap { + f.lineMapOnce.Do(func() { + f.lineMap = ls.ComputeLineStarts(f.content) + }) + return f.lineMap +} + +type diskFile struct { + fileBase + needsReload bool +} + +func newDiskFile(fileName string, content string) *diskFile { + return &diskFile{ + fileBase: fileBase{ + fileName: fileName, + content: content, + hash: xxh3.Hash128([]byte(content)), + }, + } +} + +var _ FileHandle = (*diskFile)(nil) + +func (f *diskFile) Version() int32 { + return 0 +} + +func (f *diskFile) MatchesDiskText() bool { + return !f.needsReload +} + +func (f *diskFile) IsOverlay() bool { + return false +} + +func (f *diskFile) Kind() core.ScriptKind { + return core.GetScriptKindFromFileName(f.fileName) +} + +func (f *diskFile) Clone() *diskFile { + return &diskFile{ + fileBase: fileBase{ + fileName: f.fileName, + content: f.content, + hash: f.hash, + }, + } +} + +var _ FileHandle = (*overlay)(nil) + +type overlay struct { + fileBase + version int32 + kind core.ScriptKind + matchesDiskText bool +} + +func newOverlay(fileName string, content string, version int32, kind core.ScriptKind) *overlay { + return &overlay{ + fileBase: fileBase{ + fileName: fileName, + content: content, + hash: xxh3.Hash128([]byte(content)), + }, + version: version, + kind: kind, + } +} + +func (o *overlay) Version() int32 { + return o.version +} + +func (o *overlay) Text() string { + return o.content +} + +// MatchesDiskText may return false negatives, but never false positives. +func (o *overlay) MatchesDiskText() bool { + return o.matchesDiskText +} + +// !!! optimization: incorporate mtime +func (o *overlay) computeMatchesDiskText(fs vfs.FS) bool { + if isDynamicFileName(o.fileName) { + return false + } + diskContent, ok := fs.ReadFile(o.fileName) + if !ok { + return false + } + return xxh3.Hash128([]byte(diskContent)) == o.hash +} + +func (o *overlay) IsOverlay() bool { + return true +} + +func (o *overlay) Kind() core.ScriptKind { + return o.kind +} + +type overlayFS struct { + toPath func(string) tspath.Path + fs vfs.FS + positionEncoding lsproto.PositionEncodingKind + + mu sync.RWMutex + overlays map[tspath.Path]*overlay +} + +func newOverlayFS(fs vfs.FS, overlays map[tspath.Path]*overlay, positionEncoding lsproto.PositionEncodingKind, toPath func(string) tspath.Path) *overlayFS { + return &overlayFS{ + fs: fs, + positionEncoding: positionEncoding, + overlays: overlays, + toPath: toPath, + } +} + +func (fs *overlayFS) Overlays() map[tspath.Path]*overlay { + fs.mu.RLock() + defer fs.mu.RUnlock() + return fs.overlays +} + +func (fs *overlayFS) getFile(fileName string) FileHandle { + fs.mu.RLock() + overlays := fs.overlays + fs.mu.RUnlock() + + path := fs.toPath(fileName) + if overlay, ok := overlays[path]; ok { + return overlay + } + + content, ok := fs.fs.ReadFile(fileName) + if !ok { + return nil + } + return newDiskFile(fileName, content) +} + +func (fs *overlayFS) processChanges(changes []FileChange) (FileChangeSummary, map[tspath.Path]*overlay) { + fs.mu.Lock() + defer fs.mu.Unlock() + + var includesNonWatchChange bool + var result FileChangeSummary + newOverlays := maps.Clone(fs.overlays) + + // Reduced collection of changes that occurred on a single file + type fileEvents struct { + openChange *FileChange + closeChange *FileChange + watchChanged bool + changes []*FileChange + saved bool + created bool + deleted bool + } + + fileEventMap := make(map[lsproto.DocumentUri]*fileEvents) + + for _, change := range changes { + uri := change.URI + events, exists := fileEventMap[uri] + if exists { + if events.openChange != nil { + panic("should see no changes after open") + } + } else { + events = &fileEvents{} + fileEventMap[uri] = events + } + + switch change.Kind { + case FileChangeKindOpen: + events.openChange = &change + events.closeChange = nil + events.watchChanged = false + events.changes = nil + events.saved = false + events.created = false + events.deleted = false + case FileChangeKindClose: + events.closeChange = &change + events.changes = nil + events.saved = false + events.watchChanged = false + case FileChangeKindChange: + if events.closeChange != nil { + panic("should see no changes after close") + } + events.changes = append(events.changes, &change) + events.saved = false + events.watchChanged = false + case FileChangeKindSave: + events.saved = true + case FileChangeKindWatchCreate: + if events.deleted { + // Delete followed by create becomes a change + events.deleted = false + events.watchChanged = true + } else { + events.created = true + } + case FileChangeKindWatchChange: + if !events.created { + events.watchChanged = true + events.saved = false + } + case FileChangeKindWatchDelete: + events.watchChanged = false + events.saved = false + // Delete after create cancels out + if events.created { + events.created = false + } else { + events.deleted = true + } + } + } + + // Process deduplicated events per file + for uri, events := range fileEventMap { + path := uri.Path(fs.fs.UseCaseSensitiveFileNames()) + o := newOverlays[path] + + if events.openChange != nil { + if result.Opened != "" { + panic("can only process one file open event at a time") + } + includesNonWatchChange = true + result.Opened = uri + newOverlays[path] = newOverlay( + uri.FileName(), + events.openChange.Content, + events.openChange.Version, + ls.LanguageKindToScriptKind(events.openChange.LanguageKind), + ) + continue + } + + if events.closeChange != nil { + includesNonWatchChange = true + if result.Closed == nil { + result.Closed = make(map[lsproto.DocumentUri]xxh3.Uint128) + } + result.Closed[uri] = events.closeChange.Hash + delete(newOverlays, path) + } + + 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 len(events.changes) > 0 { + includesNonWatchChange = true + result.Changed.Add(uri) + if o == nil { + panic("overlay not found for changed file: " + uri) + } + for _, change := range events.changes { + converters := ls.NewConverters(fs.positionEncoding, func(fileName string) *ls.LineMap { + return o.LineMap() + }) + for _, textChange := range change.Changes { + if partialChange := textChange.Partial; partialChange != nil { + newContent := converters.FromLSPTextChange(o, partialChange).ApplyTo(o.content) + o = newOverlay(o.fileName, newContent, change.Version, o.kind) + } else if wholeChange := textChange.WholeDocument; wholeChange != nil { + o = newOverlay(o.fileName, wholeChange.Text, change.Version, o.kind) + } + } + if len(change.Changes) > 0 { + o.version = change.Version + o.hash = xxh3.Hash128([]byte(o.content)) + o.matchesDiskText = false + newOverlays[path] = o + } + } + } + + if events.saved { + if o == nil { + panic("overlay not found for saved file: " + uri) + } + o = newOverlay(o.FileName(), o.Content(), o.Version(), o.kind) + o.matchesDiskText = true + newOverlays[path] = o + } + + if events.created && o == nil { + result.Created.Add(uri) + } + + if events.deleted && o == nil { + result.Deleted.Add(uri) + } + } + + fs.overlays = newOverlays + result.IncludesWatchChangesOnly = !includesNonWatchChange + return result, newOverlays +} diff --git a/internal/project/overlayfs_test.go b/internal/project/overlayfs_test.go new file mode 100644 index 0000000000..5f044bcacd --- /dev/null +++ b/internal/project/overlayfs_test.go @@ -0,0 +1,199 @@ +package project + +import ( + "testing" + + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestProcessChanges(t *testing.T) { + t.Parallel() + // Helper to create test overlayFS + createOverlayFS := func() *overlayFS { + testFS := vfstest.FromMap(map[string]string{ + "/test1.ts": "// existing content", + "/test2.ts": "// existing content", + }, false /* useCaseSensitiveFileNames */) + return newOverlayFS( + testFS, + make(map[tspath.Path]*overlay), + lsproto.PositionEncodingKindUTF16, + func(fileName string) tspath.Path { + return tspath.Path(fileName) + }, + ) + } + + // Test URI constants + const ( + testURI1 = lsproto.DocumentUri("file:///test1.ts") + testURI2 = lsproto.DocumentUri("file:///test2.ts") + ) + + t.Run("multiple opens should panic", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + changes := []FileChange{ + { + Kind: FileChangeKindOpen, + URI: testURI1, + Version: 1, + Content: "const x = 1;", + LanguageKind: lsproto.LanguageKindTypeScript, + }, + { + Kind: FileChangeKindOpen, + URI: testURI2, + Version: 1, + Content: "const y = 2;", + LanguageKind: lsproto.LanguageKindTypeScript, + }, + } + + assert.Assert(t, func() (panicked bool) { + defer func() { + if r := recover(); r != nil { + panicked = true + } + }() + fs.processChanges(changes) + return false + }()) + }) + + t.Run("watch create then delete becomes nothing", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + changes := []FileChange{ + { + Kind: FileChangeKindWatchCreate, + URI: testURI1, + }, + { + Kind: FileChangeKindWatchDelete, + URI: testURI1, + }, + } + + result, _ := fs.processChanges(changes) + assert.Assert(t, result.IsEmpty()) + }) + + t.Run("watch delete then create becomes change", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + changes := []FileChange{ + { + Kind: FileChangeKindWatchDelete, + URI: testURI1, + }, + { + Kind: FileChangeKindWatchCreate, + URI: testURI1, + }, + } + + result, _ := fs.processChanges(changes) + + assert.Equal(t, result.Created.Len(), 0) + assert.Equal(t, result.Deleted.Len(), 0) + assert.Assert(t, result.Changed.Has(testURI1)) + }) + + t.Run("multiple watch changes deduplicated", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + changes := []FileChange{ + { + Kind: FileChangeKindWatchChange, + URI: testURI1, + }, + { + Kind: FileChangeKindWatchChange, + URI: testURI1, + }, + { + Kind: FileChangeKindWatchChange, + URI: testURI1, + }, + } + + result, _ := fs.processChanges(changes) + + assert.Assert(t, result.Changed.Has(testURI1)) + assert.Equal(t, result.Changed.Len(), 1) + }) + + t.Run("save marks overlay as matching disk", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + // First create an overlay + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindOpen, + URI: testURI1, + Version: 1, + Content: "const x = 1;", + LanguageKind: lsproto.LanguageKindTypeScript, + }, + }) + // Then save + result, _ := fs.processChanges([]FileChange{ + { + Kind: FileChangeKindSave, + URI: testURI1, + }, + }) + // We don't observe saves for snapshot changes, + // so they're not included in the summary + assert.Assert(t, result.IsEmpty()) + + // Check that the overlay is marked as matching disk text + fh := fs.getFile(testURI1.FileName()) + assert.Assert(t, fh != nil) + assert.Assert(t, fh.MatchesDiskText()) + }) + + t.Run("watch change on overlay marks as not matching disk", func(t *testing.T) { + t.Parallel() + fs := createOverlayFS() + + // First create an overlay + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindOpen, + URI: testURI1, + Version: 1, + Content: "const x = 1;", + LanguageKind: lsproto.LanguageKindTypeScript, + }, + }) + assert.Assert(t, !fs.getFile(testURI1.FileName()).MatchesDiskText()) + + // Then save + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindSave, + URI: testURI1, + }, + }) + assert.Assert(t, fs.getFile(testURI1.FileName()).MatchesDiskText()) + + // Now process a watch change + fs.processChanges([]FileChange{ + { + Kind: FileChangeKindWatchChange, + URI: testURI1, + }, + }) + assert.Assert(t, !fs.getFile(testURI1.FileName()).MatchesDiskText()) + }) +} diff --git a/internal/project/parsecache.go b/internal/project/parsecache.go new file mode 100644 index 0000000000..caf8851584 --- /dev/null +++ b/internal/project/parsecache.go @@ -0,0 +1,99 @@ +package project + +import ( + "sync" + + "github.com/microsoft/typescript-go/internal/ast" + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/parser" + "github.com/zeebo/xxh3" +) + +type parseCacheKey struct { + ast.SourceFileParseOptions + scriptKind core.ScriptKind +} + +func newParseCacheKey( + options ast.SourceFileParseOptions, + scriptKind core.ScriptKind, +) parseCacheKey { + return parseCacheKey{ + SourceFileParseOptions: options, + scriptKind: scriptKind, + } +} + +type parseCacheEntry struct { + mu sync.Mutex + sourceFile *ast.SourceFile + hash xxh3.Uint128 + refCount int +} + +type ParseCacheOptions struct { + // DisableDeletion prevents entries from being removed from the cache. + // Used for testing. + DisableDeletion bool +} + +type ParseCache struct { + Options ParseCacheOptions + entries collections.SyncMap[parseCacheKey, *parseCacheEntry] +} + +func (c *ParseCache) Acquire( + fh FileContent, + opts ast.SourceFileParseOptions, + scriptKind core.ScriptKind, +) *ast.SourceFile { + key := newParseCacheKey(opts, scriptKind) + entry, loaded := c.loadOrStoreNewLockedEntry(key) + defer entry.mu.Unlock() + if !loaded || entry.hash != fh.Hash() { + // Reparse the file if the hash has changed, or parse for the first time. + entry.sourceFile = parser.ParseSourceFile(opts, fh.Content(), scriptKind) + entry.hash = fh.Hash() + } + return entry.sourceFile +} + +func (c *ParseCache) Ref(file *ast.SourceFile) { + key := newParseCacheKey(file.ParseOptions(), file.ScriptKind) + if entry, ok := c.entries.Load(key); ok { + entry.mu.Lock() + entry.refCount++ + entry.mu.Unlock() + } else { + panic("parse cache entry not found") + } +} + +func (c *ParseCache) Deref(file *ast.SourceFile) { + key := newParseCacheKey(file.ParseOptions(), file.ScriptKind) + if entry, ok := c.entries.Load(key); ok { + entry.mu.Lock() + entry.refCount-- + remove := entry.refCount <= 0 + entry.mu.Unlock() + if !c.Options.DisableDeletion && remove { + c.entries.Delete(key) + } + } +} + +// loadOrStoreNewLockedEntry loads an existing entry or creates a new one. The returned +// entry's mutex is locked and its refCount is incremented (or initialized to 1 in the +// case of a new entry). +func (c *ParseCache) loadOrStoreNewLockedEntry(key parseCacheKey) (*parseCacheEntry, bool) { + entry := &parseCacheEntry{refCount: 1} + entry.mu.Lock() + existing, loaded := c.entries.LoadOrStore(key, entry) + if loaded { + existing.mu.Lock() + existing.refCount++ + return existing, true + } + return entry, false +} diff --git a/internal/project/programcounter.go b/internal/project/programcounter.go new file mode 100644 index 0000000000..9d37b37b7e --- /dev/null +++ b/internal/project/programcounter.go @@ -0,0 +1,33 @@ +package project + +import ( + "sync/atomic" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/compiler" +) + +type programCounter struct { + refs collections.SyncMap[*compiler.Program, *atomic.Int32] +} + +func (c *programCounter) Ref(program *compiler.Program) { + counter, _ := c.refs.LoadOrStore(program, &atomic.Int32{}) + counter.Add(1) +} + +func (c *programCounter) Deref(program *compiler.Program) bool { + counter, ok := c.refs.Load(program) + if !ok { + panic("program not found in counter") + } + count := counter.Add(-1) + if count < 0 { + panic("program reference count went below zero") + } + if count == 0 { + c.refs.Delete(program) + return true + } + return false +} diff --git a/internal/project/project.go b/internal/project/project.go index b4a22c3a60..179b4c0207 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -1,13 +1,8 @@ package project import ( - "context" "fmt" - "slices" "strings" - "sync" - "sync/atomic" - "time" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/collections" @@ -15,63 +10,35 @@ import ( "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/module" + "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" - "github.com/microsoft/typescript-go/internal/vfs" - "github.com/microsoft/typescript-go/internal/vfs/cachedvfs" ) -//go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -output=project_stringer_generated.go -//go:generate go tool mvdan.cc/gofumpt -lang=go1.24 -w project_stringer_generated.go - -const hr = "-----------------------------------------------" +const ( + inferredProjectName = "/dev/null/inferred" // lowercase so toPath is a no-op regardless of settings + hr = "-----------------------------------------------" +) -var projectNamer = &namer{} +//go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -trimprefix=Kind -output=project_stringer_generated.go +//go:generate go tool mvdan.cc/gofumpt -lang=go1.24 -w project_stringer_generated.go type Kind int const ( KindInferred Kind = iota KindConfigured - KindAutoImportProvider - KindAuxiliary ) -type snapshot struct { - project *Project - positionEncoding lsproto.PositionEncodingKind - program *compiler.Program - lineMaps collections.SyncMap[*ast.SourceFile, *ls.LineMap] -} - -// GetLineMap implements ls.Host. -func (s *snapshot) GetLineMap(fileName string) *ls.LineMap { - file := s.program.GetSourceFile(fileName) - scriptInfo := s.project.host.DocumentStore().GetScriptInfoByPath(file.Path()) - if s.project.getFileVersion(file) == scriptInfo.Version() { - return scriptInfo.LineMap() - } - // The version changed; recompute the line map. - // !!! This shouldn't happen so often, but does. Probably removable once snapshotting is finished. - if cached, ok := s.lineMaps.Load(file); ok { - return cached - } - lineMap, _ := s.lineMaps.LoadOrStore(file, ls.ComputeLineStarts(file.Text())) - return lineMap -} - -// GetPositionEncoding implements ls.Host. -func (s *snapshot) GetPositionEncoding() lsproto.PositionEncodingKind { - return s.positionEncoding -} +type ProgramUpdateKind int -// GetProgram implements ls.Host. -func (s *snapshot) GetProgram() *compiler.Program { - return s.program -} - -var _ ls.Host = (*snapshot)(nil) +const ( + ProgramUpdateKindNone ProgramUpdateKind = iota + ProgramUpdateKindCloned + ProgramUpdateKindSameFiles + ProgramUpdateKindNewFiles +) type PendingReload int @@ -81,981 +48,292 @@ const ( PendingReloadFull ) -type ProjectHost interface { - tsoptions.ParseConfigHost - module.ResolutionHost - DefaultLibraryPath() string - TypingsInstaller() *TypingsInstaller - DocumentStore() *DocumentStore - ConfigFileRegistry() *ConfigFileRegistry - Log(s string) - PositionEncoding() lsproto.PositionEncodingKind - - IsWatchEnabled() bool - Client() Client -} - -type TypingsInfo struct { - TypeAcquisition *core.TypeAcquisition - CompilerOptions *core.CompilerOptions - UnresolvedImports []string -} - -func setIsEqualTo(arr1 []string, arr2 []string) bool { - if len(arr1) == 0 { - return len(arr2) == 0 - } - if len(arr2) == 0 { - return len(arr1) == 0 - } - if slices.Equal(arr1, arr2) { - return true - } - compact1 := slices.Clone(arr1) - compact2 := slices.Clone(arr2) - slices.Sort(compact1) - slices.Sort(compact2) - return slices.Equal(compact1, compact2) -} - -func typeAcquisitionChanged(opt1 *core.TypeAcquisition, opt2 *core.TypeAcquisition) bool { - return opt1 != opt2 && - (opt1.Enable.IsTrue() != opt2.Enable.IsTrue() || - !setIsEqualTo(opt1.Include, opt2.Include) || - !setIsEqualTo(opt1.Exclude, opt2.Exclude) || - opt1.DisableFilenameBasedTypeAcquisition.IsTrue() != opt2.DisableFilenameBasedTypeAcquisition.IsTrue()) -} - -var ( - _ compiler.CompilerHost = (*Project)(nil) - _ watchFileHost = (*Project)(nil) -) +var _ ls.Host = (*Project)(nil) +// Project represents a TypeScript project. +// If changing struct fields, also update the Clone method. type Project struct { - host *projectHostWithCachedFS - - name string - kind Kind + Kind Kind + currentDirectory string + configFileName string + configFilePath tspath.Path - mu sync.Mutex - initialLoadPending bool - dirty bool - version int - deferredClose bool - pendingReload PendingReload - dirtyFilePath tspath.Path - hasAddedorRemovedFiles atomic.Bool + dirty bool + dirtyFilePath tspath.Path - comparePathsOptions tspath.ComparePathsOptions - currentDirectory string - // Inferred projects only - rootPath tspath.Path + host *compilerHost + CommandLine *tsoptions.ParsedCommandLine + 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 - configFileName string - configFilePath tspath.Path - // rootFileNames was a map from Path to { NormalizedPath, ScriptInfo? } in the original code. - // But the ProjectService owns script infos, so it's not clear why there was an extra pointer. - rootFileNames *collections.OrderedMap[tspath.Path, string] - rootJSFileCount int - compilerOptions *core.CompilerOptions - typeAcquisition *core.TypeAcquisition - parsedCommandLine *tsoptions.ParsedCommandLine - programConfig *tsoptions.ParsedCommandLine - program *compiler.Program - checkerPool *checkerPool + failedLookupsWatch *WatchedFiles[map[tspath.Path]string] + affectingLocationsWatch *WatchedFiles[map[tspath.Path]string] - typingsCacheMu sync.Mutex - unresolvedImportsPerFile map[*ast.SourceFile][]string - unresolvedImports []string - typingsInfo *TypingsInfo - typingFiles []string + checkerPool *checkerPool - // Watchers - failedLookupsWatch *watchedFiles[map[tspath.Path]string] - affectingLocationsWatch *watchedFiles[map[tspath.Path]string] - typingsFilesWatch *watchedFiles[map[tspath.Path]string] - typingsDirectoryWatch *watchedFiles[map[tspath.Path]string] - typingsWatchInvoked atomic.Bool + // 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 } func NewConfiguredProject( configFileName string, configFilePath tspath.Path, - host ProjectHost, + builder *projectCollectionBuilder, + logger *logging.LogTree, ) *Project { - project := NewProject(configFileName, KindConfigured, tspath.GetDirectoryPath(configFileName), host) - project.configFileName = configFileName - project.configFilePath = configFilePath - project.initialLoadPending = true - project.pendingReload = PendingReloadFull - return project + return NewProject(configFileName, KindConfigured, tspath.GetDirectoryPath(configFileName), builder, logger) } func NewInferredProject( - compilerOptions *core.CompilerOptions, currentDirectory string, - projectRootPath tspath.Path, - host ProjectHost, + compilerOptions *core.CompilerOptions, + rootFileNames []string, + builder *projectCollectionBuilder, + logger *logging.LogTree, ) *Project { - project := NewProject(projectNamer.next("/dev/null/inferredProject"), KindInferred, currentDirectory, host) - project.rootPath = projectRootPath - project.compilerOptions = compilerOptions - return 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.ScriptTargetES2022, + Jsx: core.JsxEmitReactJSX, + AllowImportingTsExtensions: core.TSTrue, + StrictNullChecks: core.TSTrue, + StrictFunctionTypes: core.TSTrue, + SourceMap: core.TSTrue, + ESModuleInterop: 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(name string, kind Kind, currentDirectory string, host ProjectHost) *Project { - cachedHost := newProjectHostWithCachedFS(host) - - host.Log(fmt.Sprintf("Creating %sProject: %s, currentDirectory: %s", kind.String(), name, currentDirectory)) +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{ - host: cachedHost, - name: name, - kind: kind, + configFileName: configFileName, + Kind: kind, currentDirectory: currentDirectory, - rootFileNames: &collections.OrderedMap[tspath.Path, string]{}, dirty: true, } - project.comparePathsOptions = tspath.ComparePathsOptions{ - CurrentDirectory: currentDirectory, - UseCaseSensitiveFileNames: project.host.FS().UseCaseSensitiveFileNames(), - } - client := project.Client() - if project.host.IsWatchEnabled() && client != nil { - globMapper := createResolutionLookupGlobMapper(project.host) - project.failedLookupsWatch = newWatchedFiles(project, lsproto.WatchKindCreate, globMapper, "failed lookup") - project.affectingLocationsWatch = newWatchedFiles(project, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, globMapper, "affecting location") - project.typingsFilesWatch = newWatchedFiles(project, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, globMapperForTypingsInstaller, "typings installer files") - project.typingsDirectoryWatch = newWatchedFiles(project, lsproto.WatchKindCreate|lsproto.WatchKindDelete, globMapperForTypingsInstaller, "typings installer directories") - } - project.markAsDirty() - return project -} - -type projectHostWithCachedFS struct { - ProjectHost - fs *cachedvfs.FS -} - -func newProjectHostWithCachedFS(host ProjectHost) *projectHostWithCachedFS { - newHost := &projectHostWithCachedFS{ - ProjectHost: host, - fs: cachedvfs.From(host.FS()), - } - newHost.fs.DisableAndClearCache() - return newHost -} - -func (p *projectHostWithCachedFS) FS() vfs.FS { - return p.fs -} -func (p *Project) Client() Client { - return p.host.Client() -} - -// FS implements compiler.CompilerHost. -func (p *Project) FS() vfs.FS { - return p.host.FS() -} - -// DefaultLibraryPath implements compiler.CompilerHost. -func (p *Project) DefaultLibraryPath() string { - return p.host.DefaultLibraryPath() -} - -// GetCurrentDirectory implements compiler.CompilerHost. -func (p *Project) GetCurrentDirectory() string { - return p.currentDirectory -} - -func (p *Project) GetRootFileNames() []string { - return append(slices.Collect(p.rootFileNames.Values()), p.typingFiles...) -} - -func (p *Project) GetCompilerOptions() *core.CompilerOptions { - return p.compilerOptions -} - -// GetSourceFile implements compiler.CompilerHost. -func (p *Project) GetSourceFile(opts ast.SourceFileParseOptions) *ast.SourceFile { - scriptKind := p.getScriptKind(opts.FileName) - if scriptInfo := p.getOrCreateScriptInfoAndAttachToProject(opts.FileName, scriptKind); scriptInfo != nil { - var oldSourceFile *ast.SourceFile - if p.program != nil { - oldSourceFile = p.program.GetSourceFileByPath(scriptInfo.path) - } - return p.host.DocumentStore().documentRegistry.AcquireDocument(scriptInfo, opts, oldSourceFile) + project.configFilePath = tspath.ToPath(configFileName, currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()) + if builder.sessionOptions.WatchEnabled { + project.failedLookupsWatch = NewWatchedFiles( + "failed lookups for "+configFileName, + lsproto.WatchKindCreate, + createResolutionLookupGlobMapper(project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()), + ) + project.affectingLocationsWatch = NewWatchedFiles( + "affecting locations for "+configFileName, + lsproto.WatchKindCreate, + createResolutionLookupGlobMapper(project.currentDirectory, builder.fs.fs.UseCaseSensitiveFileNames()), + ) } - return nil -} - -// GetResolvedProjectReference implements compiler.CompilerHost. -func (p *Project) GetResolvedProjectReference(fileName string, path tspath.Path) *tsoptions.ParsedCommandLine { - return p.host.ConfigFileRegistry().acquireConfig(fileName, path, p, nil) -} - -// Updates the program if needed. -func (p *Project) GetProgram() *compiler.Program { - program, _ := p.updateGraph() - return program -} - -// Trace implements compiler.CompilerHost. -func (p *Project) Trace(msg string) { - p.host.Log(msg) -} - -// GetDefaultLibraryPath implements compiler.CompilerHost. -func (p *Project) GetDefaultLibraryPath() string { - return p.host.DefaultLibraryPath() + return project } func (p *Project) Name() string { - return p.name -} - -func (p *Project) Kind() Kind { - return p.kind -} - -func (p *Project) Version() int { - return p.version -} - -func (p *Project) CurrentProgram() *compiler.Program { - return p.program -} - -func (p *Project) GetLanguageServiceForRequest(ctx context.Context) (*ls.LanguageService, func()) { - if core.GetRequestID(ctx) == "" { - panic("context must already have a request ID") - } - program := p.GetProgram() - if program == nil { - panic("must have gced by other request") - } - checkerPool := p.checkerPool - snapshot := &snapshot{ - project: p, - positionEncoding: p.host.PositionEncoding(), - program: program, - } - languageService := ls.NewLanguageService(snapshot) - cleanup := func() { - if checkerPool.isRequestCheckerInUse(core.GetRequestID(ctx)) { - panic(fmt.Errorf("checker for request ID %s not returned to pool at end of request", core.GetRequestID(ctx))) - } - } - return languageService, cleanup + return p.configFileName } -func (p *Project) updateModuleResolutionWatches(ctx context.Context) { - client := p.Client() - if !p.host.IsWatchEnabled() || client == nil { - return +// ConfigFileName panics if Kind() is not KindConfigured. +func (p *Project) ConfigFileName() string { + if p.Kind != KindConfigured { + panic("ConfigFileName called on non-configured project") } - - failedLookups := make(map[tspath.Path]string) - affectingLocations := make(map[tspath.Path]string) - extractLookups(p, failedLookups, affectingLocations, p.program.GetResolvedModules()) - extractLookups(p, failedLookups, affectingLocations, p.program.GetResolvedTypeReferenceDirectives()) - - p.failedLookupsWatch.update(ctx, failedLookups) - p.affectingLocationsWatch.update(ctx, affectingLocations) -} - -type ResolutionWithLookupLocations interface { - GetLookupLocations() *module.LookupLocations + return p.configFileName } -func extractLookups[T ResolutionWithLookupLocations]( - p *Project, - failedLookups map[tspath.Path]string, - affectingLocations map[tspath.Path]string, - cache map[tspath.Path]module.ModeAwareCache[T], -) { - for _, resolvedModulesInFile := range cache { - for _, resolvedModule := range resolvedModulesInFile { - for _, failedLookupLocation := range resolvedModule.GetLookupLocations().FailedLookupLocations { - path := p.toPath(failedLookupLocation) - if _, ok := failedLookups[path]; !ok { - failedLookups[path] = failedLookupLocation - } - } - for _, affectingLocation := range resolvedModule.GetLookupLocations().AffectingLocations { - path := p.toPath(affectingLocation) - if _, ok := affectingLocations[path]; !ok { - affectingLocations[path] = affectingLocation - } - } - } +// 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 } -// onWatchEventForNilScriptInfo is fired for watch events that are not the -// project tsconfig, and do not have a ScriptInfo for the associated file. -// This could be a case of one of the following: -// - A file is being created that will be added to the project. -// - An affecting location was changed. -// - A file is being created that matches a watch glob, but is not actually -// part of the project, e.g., a .js file in a project without --allowJs. -func (p *Project) onWatchEventForNilScriptInfo(fileName string) { - path := p.toPath(fileName) - if _, ok := p.failedLookupsWatch.data[path]; ok { - p.markAsDirty() - } else if _, ok := p.affectingLocationsWatch.data[path]; ok { - p.markAsDirty() - } - - if !p.typingsWatchInvoked.Load() { - if _, ok := p.typingsFilesWatch.data[path]; ok { - p.typingsWatchInvoked.Store(true) - p.enqueueInstallTypingsForProject(nil, true) - } else if _, ok := p.typingsDirectoryWatch.data[path]; ok { - p.typingsWatchInvoked.Store(true) - p.enqueueInstallTypingsForProject(nil, true) - } else { - for dir := range p.typingsDirectoryWatch.data { - if tspath.ContainsPath(string(dir), string(path), p.comparePathsOptions) { - p.typingsWatchInvoked.Store(true) - p.enqueueInstallTypingsForProject(nil, true) - break - } - } - } - } -} - -func (p *Project) getOrCreateScriptInfoAndAttachToProject(fileName string, scriptKind core.ScriptKind) *ScriptInfo { - if scriptInfo := p.host.DocumentStore().getOrCreateScriptInfoWorker(fileName, p.toPath(fileName), scriptKind, false, "", false, p.host.FS()); scriptInfo != nil { - scriptInfo.attachToProject(p) - return scriptInfo - } - return nil -} - -func (p *Project) getScriptKind(fileName string) core.ScriptKind { - // Customizing script kind per file extension is a common plugin / LS host customization case - // which can probably be replaced with static info in the future - return core.GetScriptKindFromFileName(fileName) -} - -func (p *Project) MarkFileAsDirty(path tspath.Path) { - p.mu.Lock() - defer p.mu.Unlock() - if !p.dirty { - p.dirty = true - p.dirtyFilePath = path - p.version++ - } else if path != p.dirtyFilePath { - p.dirtyFilePath = "" - } -} - -func (p *Project) SetPendingReload(level PendingReload) { - p.mu.Lock() - defer p.mu.Unlock() - if level > p.pendingReload { - p.pendingReload = level - p.markAsDirtyLocked() - } -} - -func (p *Project) markAsDirty() { - p.mu.Lock() - defer p.mu.Unlock() - p.markAsDirtyLocked() -} - -func (p *Project) markAsDirtyLocked() { - p.dirtyFilePath = "" - if !p.dirty { - p.dirty = true - p.version++ - } +// GetProgram implements ls.Host. +func (p *Project) GetProgram() *compiler.Program { + return p.Program } -// Always called when p.mu lock was already acquired. -func (p *Project) onFileAddedOrRemoved() { - p.hasAddedorRemovedFiles.Store(true) +func (p *Project) containsFile(path tspath.Path) bool { + return p.Program != nil && p.Program.GetSourceFileByPath(path) != nil } -// updateGraph updates the set of files that contribute to the project. -// Returns true if the set of files in has changed. NOTE: this is the -// opposite of the return value in Strada, which was frequently inverted, -// as in `updateProjectIfDirty()`. -func (p *Project) updateGraph() (*compiler.Program, bool) { - p.mu.Lock() - defer p.mu.Unlock() - - if !p.dirty || p.isClosed() { - return p.program, false - } - - p.host.fs.Enable() - defer p.host.fs.DisableAndClearCache() - - start := time.Now() - p.Log("Starting updateGraph: Project: " + p.name) - oldProgram := p.program - p.initialLoadPending = false - - if p.kind == KindConfigured && p.pendingReload != PendingReloadNone { - switch p.pendingReload { - case PendingReloadFileNames: - p.parsedCommandLine = p.GetResolvedProjectReference(p.configFileName, p.configFilePath) - p.setRootFiles(p.parsedCommandLine.FileNames()) - p.programConfig = nil - p.pendingReload = PendingReloadNone - case PendingReloadFull: - err := p.LoadConfig() - if err != nil { - panic(fmt.Sprintf("failed to reload config: %v", err)) - } - } - } - oldProgramReused := p.updateProgram() - hasAddedOrRemovedFiles := p.hasAddedorRemovedFiles.Load() - p.hasAddedorRemovedFiles.Store(false) - p.dirty = false - p.dirtyFilePath = "" - if hasAddedOrRemovedFiles { - p.Log(p.print(true /*writeFileNames*/, true /*writeFileExplanation*/, false /*writeFileVersionAndText*/, &strings.Builder{})) - } else if p.program != oldProgram { - p.Log("Different program with same set of root files") - } - if !oldProgramReused { - if oldProgram != nil { - for _, oldSourceFile := range oldProgram.GetSourceFiles() { - if p.program.GetSourceFileByPath(oldSourceFile.Path()) == nil { - p.host.DocumentStore().documentRegistry.ReleaseDocument(oldSourceFile) - p.detachScriptInfoIfNotInferredRoot(oldSourceFile.Path()) - } - } - - oldProgram.ForEachResolvedProjectReference(func(path tspath.Path, ref *tsoptions.ParsedCommandLine, parent *tsoptions.ParsedCommandLine, index int) { - if _, ok := p.program.GetResolvedProjectReferenceFor(path); !ok { - p.host.ConfigFileRegistry().releaseConfig(path, p) - } - }) - } - p.enqueueInstallTypingsForProject(oldProgram, hasAddedOrRemovedFiles) - // TODO: this is currently always synchronously called by some kind of updating request, - // but in Strada we throttle, so at least sometimes this should be considered top-level? - p.updateModuleResolutionWatches(context.TODO()) - } - p.Logf("Finishing updateGraph: Project: %s version: %d in %s", p.name, p.version, time.Since(start)) - return p.program, true +func (p *Project) IsSourceFromProjectReference(path tspath.Path) bool { + return p.Program != nil && p.Program.IsSourceFromProjectReference(path) } -func (p *Project) updateProgram() bool { - if p.checkerPool != nil { - p.Logf("Program %d used %d checker(s)", p.version, p.checkerPool.size()) - } - var oldProgramReused bool - if p.program == nil || p.dirtyFilePath == "" { - if p.programConfig == nil { - // Get from config file = config file root files + typings files - if p.parsedCommandLine != nil { - // There are no typing files so use the parsed command line as is - if len(p.typingFiles) == 0 { - p.programConfig = p.parsedCommandLine - } else { - // Update the fileNames - parsedConfig := *p.parsedCommandLine.ParsedConfig - parsedConfig.FileNames = append(p.parsedCommandLine.FileNames(), p.typingFiles...) - p.programConfig = &tsoptions.ParsedCommandLine{ - ParsedConfig: &parsedConfig, - ConfigFile: p.parsedCommandLine.ConfigFile, - Errors: p.parsedCommandLine.Errors, - } - } - } else { - rootFileNames := p.GetRootFileNames() - compilerOptions := p.compilerOptions +func (p *Project) Clone() *Project { + return &Project{ + Kind: p.Kind, + currentDirectory: p.currentDirectory, + configFileName: p.configFileName, + configFilePath: p.configFilePath, - if compilerOptions.MaxNodeModuleJsDepth == nil && p.rootJSFileCount > 0 { - compilerOptions = compilerOptions.Clone() - compilerOptions.MaxNodeModuleJsDepth = ptrTo(2) - } + dirty: p.dirty, + dirtyFilePath: p.dirtyFilePath, - p.programConfig = &tsoptions.ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ - CompilerOptions: compilerOptions, - FileNames: rootFileNames, - }, - } - } - } - var typingsLocation string - if typeAcquisition := p.getTypeAcquisition(); typeAcquisition != nil && typeAcquisition.Enable.IsTrue() { - typingsInstaller := p.host.TypingsInstaller() - if typingsInstaller != nil { - typingsLocation = typingsInstaller.TypingsLocation - } - } - p.program = compiler.NewProgram(compiler.ProgramOptions{ - Config: p.programConfig, - Host: p, - UseSourceOfProjectReference: true, - TypingsLocation: typingsLocation, - CreateCheckerPool: func(program *compiler.Program) compiler.CheckerPool { - p.checkerPool = newCheckerPool(4, program, p.Log) - return p.checkerPool - }, - JSDocParsingMode: ast.JSDocParsingModeParseAll, - }) - } else { - // The only change in the current program is the contents of the file named by p.dirtyFilePath. - // If possible, use data from the old program to create the new program. - p.program, oldProgramReused = p.program.UpdateProgram(p.dirtyFilePath) - } - p.program.BindSourceFiles() - return oldProgramReused -} + host: p.host, + CommandLine: p.CommandLine, + Program: p.Program, + ProgramUpdateKind: ProgramUpdateKindNone, + ProgramLastUpdate: p.ProgramLastUpdate, -func (p *Project) allRootFilesAreJsOrDts() bool { - for _, fileName := range p.rootFileNames.Entries() { - switch p.getScriptKind(fileName) { - case core.ScriptKindTS: - if tspath.IsDeclarationFileName(fileName) { - break - } - fallthrough - case core.ScriptKindTSX: - return false - } - } - return true -} + failedLookupsWatch: p.failedLookupsWatch, + affectingLocationsWatch: p.affectingLocationsWatch, -func (p *Project) getTypeAcquisition() *core.TypeAcquisition { - // !!! sheetal Remove local @types from include list which was done in Strada - if p.kind == KindInferred && p.typeAcquisition == nil { - var enable core.Tristate - if p.allRootFilesAreJsOrDts() { - enable = core.TSTrue - } - p.typeAcquisition = &core.TypeAcquisition{ - Enable: enable, - } - } - return p.typeAcquisition -} + checkerPool: p.checkerPool, -func (p *Project) setTypeAcquisition(typeAcquisition *core.TypeAcquisition) { - if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { - p.unresolvedImports = nil - p.unresolvedImportsPerFile = nil - p.typingFiles = nil + installedTypingsInfo: p.installedTypingsInfo, + typingsFiles: p.typingsFiles, } - p.typeAcquisition = typeAcquisition } -func (p *Project) enqueueInstallTypingsForProject(oldProgram *compiler.Program, forceRefresh bool) { - typingsInstaller := p.host.TypingsInstaller() - if typingsInstaller == nil { - return +// getCommandLineWithTypingsFiles returns the command line augmented with typing files if ATA is enabled. +// !!! Need to cache this for equality comparison in CreateProgram +func (p *Project) getCommandLineWithTypingsFiles() *tsoptions.ParsedCommandLine { + if len(p.typingsFiles) == 0 { + return p.CommandLine } - typeAcquisition := p.getTypeAcquisition() + // Check if ATA is enabled for this project + typeAcquisition := p.GetTypeAcquisition() if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { - return - } - - p.typingsCacheMu.Lock() - unresolvedImports := p.extractUnresolvedImports(oldProgram) - if forceRefresh || - p.typingsInfo == nil || - p.typingsInfo.CompilerOptions.GetAllowJS() != p.compilerOptions.GetAllowJS() || - typeAcquisitionChanged(typeAcquisition, p.typingsInfo.TypeAcquisition) || - !slices.Equal(p.typingsInfo.UnresolvedImports, unresolvedImports) { - // Note: entry is now poisoned since it does not really contain typings for a given combination of compiler options\typings options. - // instead it acts as a placeholder to prevent issuing multiple requests - typingsInfo := &TypingsInfo{ - TypeAcquisition: typeAcquisition, - CompilerOptions: p.compilerOptions, - UnresolvedImports: unresolvedImports, - } - p.typingsInfo = typingsInfo - p.typingsCacheMu.Unlock() - // something has been changed, issue a request to update typings - typingsInstaller.EnqueueInstallTypingsRequest(p, typingsInfo) - } else { - p.typingsCacheMu.Unlock() - } -} - -func (p *Project) extractUnresolvedImports(oldProgram *compiler.Program) []string { - // We dont want to this unless imports/resolutions have changed for any of the file - for later - - // tracing?.push(tracing.Phase.Session, "getUnresolvedImports", { count: sourceFiles.length }); - hasChanges := false - sourceFiles := p.program.GetSourceFiles() - sourceFilesSet := collections.NewSetWithSizeHint[*ast.SourceFile](len(sourceFiles)) - - // !!! sheetal remove ambient module names from unresolved imports - // const ambientModules = program.getTypeChecker().getAmbientModules().map(mod => stripQuotes(mod.getName())); - for _, sourceFile := range sourceFiles { - if p.extractUnresolvedImportsFromSourceFile(sourceFile, oldProgram) { - hasChanges = true - } - sourceFilesSet.Add(sourceFile) - } - - if hasChanges || len(p.unresolvedImportsPerFile) != sourceFilesSet.Len() { - unResolvedImports := []string{} - for sourceFile, unResolvedInFile := range p.unresolvedImportsPerFile { - if sourceFilesSet.Has(sourceFile) { - unResolvedImports = append(unResolvedImports, unResolvedInFile...) - } else { - delete(p.unresolvedImportsPerFile, sourceFile) - } - } - - slices.Sort(unResolvedImports) - p.unresolvedImports = slices.Compact(unResolvedImports) - } - // tracing?.pop(); - return p.unresolvedImports -} - -func (p *Project) extractUnresolvedImportsFromSourceFile(file *ast.SourceFile, oldProgram *compiler.Program) bool { - _, ok := p.unresolvedImportsPerFile[file] - if ok { - return false - } - - unresolvedImports := []string{} - resolvedModules := p.program.GetResolvedModules()[file.Path()] - for cacheKey, resolution := range resolvedModules { - resolved := resolution.IsResolved() - if (!resolved || !tspath.ExtensionIsOneOf(resolution.Extension, tspath.SupportedTSExtensionsWithJsonFlat)) && - !tspath.IsExternalModuleNameRelative(cacheKey.Name) { - // !ambientModules.some(m => m === name) - unresolvedImports = append(unresolvedImports, cacheKey.Name) - } - } - - hasChanges := true - if oldProgram != nil { - oldFile := oldProgram.GetSourceFileByPath(file.Path()) - if oldFile != nil { - oldUnresolvedImports, ok := p.unresolvedImportsPerFile[oldFile] - if ok { - delete(p.unresolvedImportsPerFile, oldFile) - if slices.Equal(oldUnresolvedImports, unresolvedImports) { - unresolvedImports = oldUnresolvedImports - } else { - hasChanges = true + return p.CommandLine + } + + // 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 + return tsoptions.NewParsedCommandLine( + p.CommandLine.CompilerOptions(), + newRootNames, + tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: p.host.FS().UseCaseSensitiveFileNames(), + CurrentDirectory: p.currentDirectory, + }, + ) +} + +type CreateProgramResult struct { + Program *compiler.Program + UpdateKind ProgramUpdateKind + CheckerPool *checkerPool +} + +func (p *Project) CreateProgram() CreateProgramResult { + updateKind := ProgramUpdateKindNewFiles + var programCloned bool + var checkerPool *checkerPool + var newProgram *compiler.Program + + // Create the command line, potentially augmented with typing files + commandLine := p.getCommandLineWithTypingsFiles() + + if p.dirtyFilePath != "" && p.Program != nil && p.Program.CommandLine() == commandLine { + newProgram, programCloned = p.Program.UpdateProgram(p.dirtyFilePath, p.host) + if programCloned { + updateKind = ProgramUpdateKindCloned + for _, file := range newProgram.GetSourceFiles() { + if file.Path() != p.dirtyFilePath { + // UpdateProgram only called host.GetSourceFile for the dirty file. + // Increment ref count for all other files. + p.host.builder.parseCache.Ref(file) } - } } - } - if p.unresolvedImportsPerFile == nil { - p.unresolvedImportsPerFile = make(map[*ast.SourceFile][]string, len(p.program.GetSourceFiles())) - } - p.unresolvedImportsPerFile[file] = unresolvedImports - return hasChanges -} - -func (p *Project) UpdateTypingFiles(typingsInfo *TypingsInfo, typingFiles []string) { - p.mu.Lock() - defer p.mu.Unlock() - if p.isClosed() || p.typingsInfo != typingsInfo { - return - } - - typeAcquisition := p.getTypeAcquisition() - if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { - typingFiles = nil } else { - slices.Sort(typingFiles) - } - if !slices.Equal(typingFiles, p.typingFiles) { - // If typing files changed, then only schedule project update - p.typingFiles = typingFiles - p.programConfig = nil - - // // Invalidate files with unresolved imports - // this.resolutionCache.setFilesWithInvalidatedNonRelativeUnresolvedImports(this.cachedUnresolvedImportsPerFile); - - p.markAsDirtyLocked() - client := p.Client() - if client != nil { - err := client.RefreshDiagnostics(context.Background()) - if err != nil { - p.Logf("Error when refreshing diagnostics from updateTypingFiles %v", err) - } - } - } -} - -func (p *Project) WatchTypingLocations(files []string) { - p.mu.Lock() - defer p.mu.Unlock() - if p.isClosed() { - return - } - - client := p.Client() - if !p.host.IsWatchEnabled() || client == nil { - return - } - - p.typingsWatchInvoked.Store(false) - var typingsInstallerFileGlobs map[tspath.Path]string - var typingsInstallerDirectoryGlobs map[tspath.Path]string - // Create watches from list of files - for _, file := range files { - basename := tspath.GetBaseFileName(file) - if basename == "package.json" || basename == "bower.json" { - // package.json or bower.json exists, watch the file to detect changes and update typings - if typingsInstallerFileGlobs == nil { - typingsInstallerFileGlobs = map[tspath.Path]string{} - } - typingsInstallerFileGlobs[p.toPath(file)] = file - } else { - var globLocation string - // path in projectRoot, watch project root - if tspath.ContainsPath(p.currentDirectory, file, p.comparePathsOptions) { - currentDirectoryLen := len(p.currentDirectory) + 1 - subDirectory := strings.IndexRune(file[currentDirectoryLen:], tspath.DirectorySeparator) - if subDirectory != -1 { - // Watch subDirectory - globLocation = file[0 : currentDirectoryLen+subDirectory] - } else { - // Watch the directory itself - globLocation = file - } - } else { - // path in global cache, watch global cache - // else watch node_modules or bower_components - typingsLocation := p.host.TypingsInstaller().TypingsLocation - globLocation = core.IfElse(tspath.ContainsPath(typingsLocation, file, p.comparePathsOptions), typingsLocation, file) - } - // package.json or bower.json exists, watch the file to detect changes and update typings - if typingsInstallerDirectoryGlobs == nil { - typingsInstallerDirectoryGlobs = map[tspath.Path]string{} - } - typingsInstallerDirectoryGlobs[p.toPath(globLocation)] = fmt.Sprintf("%s/%s", globLocation, recursiveFileGlobPattern) + var typingsLocation string + if p.GetTypeAcquisition().Enable.IsTrue() { + typingsLocation = p.host.sessionOptions.TypingsLocation } - } - ctx := context.Background() - p.typingsFilesWatch.update(ctx, typingsInstallerFileGlobs) - p.typingsDirectoryWatch.update(ctx, typingsInstallerDirectoryGlobs) -} - -func (p *Project) isSourceFromProjectReference(info *ScriptInfo) bool { - program := p.program - return program != nil && program.IsSourceFromProjectReference(info.Path()) -} - -func (p *Project) containsScriptInfo(info *ScriptInfo) bool { - if p.isRoot(info) { - return true - } - program := p.program - return program != nil && program.GetSourceFileByPath(info.Path()) != nil -} - -func (p *Project) isOrphan() bool { - switch p.kind { - case KindInferred: - return p.rootFileNames.Size() == 0 - case KindConfigured: - return p.deferredClose - default: - panic("unhandled project kind") - } -} - -func (p *Project) toPath(fileName string) tspath.Path { - return tspath.ToPath(fileName, p.GetCurrentDirectory(), p.FS().UseCaseSensitiveFileNames()) -} - -func (p *Project) isRoot(info *ScriptInfo) bool { - return p.rootFileNames.Has(info.path) -} - -func (p *Project) RemoveFile(info *ScriptInfo, fileExists bool) { - p.mu.Lock() - defer p.mu.Unlock() - if p.isRoot(info) && p.kind == KindInferred { - p.deleteRootFileNameOfInferred(info.path) - p.setTypeAcquisition(nil) - p.programConfig = nil - } - p.onFileAddedOrRemoved() - - // !!! - // if (fileExists) { - // // If file is present, just remove the resolutions for the file - // this.resolutionCache.removeResolutionsOfFile(info.path); - // } else { - // this.resolutionCache.invalidateResolutionOfFile(info.path); - // } - // this.cachedUnresolvedImportsPerFile.delete(info.path); - p.markAsDirtyLocked() -} - -func (p *Project) AddInferredProjectRoot(info *ScriptInfo) { - p.mu.Lock() - defer p.mu.Unlock() - if p.isRoot(info) { - panic("script info is already a root") - } - p.setRootFileNameOfInferred(info.path, info.fileName) - p.programConfig = nil - p.setTypeAcquisition(nil) - // !!! - // if p.kind == KindInferred { - // p.host.startWatchingConfigFilesForInferredProjectRoot(info.path); - // } - info.attachToProject(p) - p.markAsDirtyLocked() -} - -func (p *Project) LoadConfig() error { - if p.kind != KindConfigured { - panic("loadConfig called on non-configured project") - } - - p.programConfig = nil - p.pendingReload = PendingReloadNone - p.parsedCommandLine = p.GetResolvedProjectReference(p.configFileName, p.configFilePath) - if p.parsedCommandLine != nil { - p.Logf("Config: %s : %s", - p.configFileName, - core.Must(core.StringifyJson(map[string]any{ - "rootNames": p.parsedCommandLine.FileNames(), - "options": p.parsedCommandLine.CompilerOptions(), - "projectReferences": p.parsedCommandLine.ProjectReferences(), - }, " ", " ")), + newProgram = compiler.NewProgram( + compiler.ProgramOptions{ + Host: p.host, + Config: commandLine, + UseSourceOfProjectReference: true, + TypingsLocation: typingsLocation, + JSDocParsingMode: ast.JSDocParsingModeParseAll, + CreateCheckerPool: func(program *compiler.Program) compiler.CheckerPool { + checkerPool = newCheckerPool(4, program, p.log) + return checkerPool + }, + }, ) - - p.compilerOptions = p.parsedCommandLine.CompilerOptions() - p.setTypeAcquisition(p.parsedCommandLine.TypeAcquisition()) - p.setRootFiles(p.parsedCommandLine.FileNames()) - } else { - p.compilerOptions = &core.CompilerOptions{} - p.setTypeAcquisition(nil) - return fmt.Errorf("could not read file %q", p.configFileName) - } - return nil -} - -// setRootFiles returns true if the set of root files has changed. -func (p *Project) setRootFiles(rootFileNames []string) { - newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames)) - for _, file := range rootFileNames { - path := p.toPath(file) - // !!! updateNonInferredProjectFiles uses a fileExists check, which I guess - // could be needed if a watcher fails? - newRootScriptInfos[path] = struct{}{} - p.rootFileNames.Set(path, file) - // if !isAlreadyRoot { - // if scriptInfo.isOpen { - // !!!s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) - // } - // } - } - - if p.rootFileNames.Size() > len(rootFileNames) { - for root := range p.rootFileNames.Keys() { - if _, ok := newRootScriptInfos[root]; !ok { - p.rootFileNames.Delete(root) - } + if p.Program != nil && p.Program.HasSameFileNames(newProgram) { + updateKind = ProgramUpdateKindSameFiles } } -} -func (p *Project) setRootFileNameOfInferred(path tspath.Path, fileName string) { - if p.kind != KindInferred { - panic("setRootFileNameOfInferred called on non-inferred project") - } + newProgram.BindSourceFiles() - has := p.rootFileNames.Has(path) - p.rootFileNames.Set(path, fileName) - if !has && tspath.HasJSFileExtension(fileName) { - p.rootJSFileCount++ + return CreateProgramResult{ + Program: newProgram, + UpdateKind: updateKind, + CheckerPool: checkerPool, } } -func (p *Project) deleteRootFileNameOfInferred(path tspath.Path) { - if p.kind != KindInferred { - panic("deleteRootFileNameOfInferred called on non-inferred project") - } - - fileName, ok := p.rootFileNames.Get(path) - if !ok { - return - } - p.rootFileNames.Delete(path) - if tspath.HasJSFileExtension(fileName) { - p.rootJSFileCount-- - } +func (p *Project) CloneWatchers() (failedLookupsWatch *WatchedFiles[map[tspath.Path]string], affectingLocationsWatch *WatchedFiles[map[tspath.Path]string]) { + failedLookups := make(map[tspath.Path]string) + affectingLocations := make(map[tspath.Path]string) + extractLookups(p.toPath, failedLookups, affectingLocations, p.Program.GetResolvedModules()) + extractLookups(p.toPath, failedLookups, affectingLocations, p.Program.GetResolvedTypeReferenceDirectives()) + failedLookupsWatch = p.failedLookupsWatch.Clone(failedLookups) + affectingLocationsWatch = p.affectingLocationsWatch.Clone(affectingLocations) + return failedLookupsWatch, affectingLocationsWatch } -func (p *Project) clearSourceMapperCache() { +func (p *Project) log(msg string) { // !!! } -func (p *Project) GetFileNames(excludeFilesFromExternalLibraries bool, excludeConfigFiles bool) []string { - if p.program == nil { - return []string{} - } - - // !!! sheetal incomplete code - // if (!this.languageServiceEnabled) { - // // if language service is disabled assume that all files in program are root files + default library - // let rootFiles = this.getRootFiles(); - // if (this.compilerOptions) { - // const defaultLibrary = getDefaultLibFilePath(this.compilerOptions); - // if (defaultLibrary) { - // (rootFiles || (rootFiles = [])).push(asNormalizedPath(defaultLibrary)); - // } - // } - // return rootFiles; - // } - result := []string{} - sourceFiles := p.program.GetSourceFiles() - for _, sourceFile := range sourceFiles { - if excludeFilesFromExternalLibraries && p.program.IsSourceFileFromExternalLibrary(sourceFile) { - continue - } - result = append(result, sourceFile.FileName()) - } - // if (!excludeConfigFiles) { - // const configFile = p.program.GetCompilerOptions().configFile; - // if (configFile) { - // result = append(result, configFile.fileName); - // if (configFile.extendedSourceFiles) { - // for (const f of configFile.extendedSourceFiles) { - // result.push(asNormalizedPath(f)); - // } - // } - // } - // } - return result +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, writeFileVersionAndText bool, builder *strings.Builder) string { - builder.WriteString(fmt.Sprintf("\nProject '%s' (%s)\n", p.name, p.kind.String())) - if p.initialLoadPending { - builder.WriteString("\n\tFiles (0) InitialLoadPending\n") - } else if p.program == nil { - builder.WriteString("\n\tFiles (0) NoProgram\n") +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("\n\tFiles (%d)\n", len(sourceFiles))) + sourceFiles := p.Program.GetSourceFiles() + builder.WriteString(fmt.Sprintf("\tFiles (%d)\n", len(sourceFiles))) if writeFileNames { for _, sourceFile := range sourceFiles { - builder.WriteString("\n\t\t" + sourceFile.FileName()) - if writeFileVersionAndText { - builder.WriteString(fmt.Sprintf(" %d %s", p.getFileVersion(sourceFile), sourceFile.Text())) - } + builder.WriteString("\t\t" + sourceFile.FileName() + "\n") } // !!! // if writeFileExplanation {} @@ -1065,91 +343,56 @@ func (p *Project) print(writeFileNames bool, writeFileExplanation bool, writeFil return builder.String() } -func (p *Project) getFileVersion(file *ast.SourceFile) int { - return p.host.DocumentStore().documentRegistry.getFileVersion(file) -} +// 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, + } + } -func (p *Project) Log(s string) { - p.host.Log(s) -} + if p.CommandLine != nil { + return p.CommandLine.TypeAcquisition() + } -func (p *Project) Logf(format string, args ...interface{}) { - p.Log(fmt.Sprintf(format, args...)) + return nil } -func (p *Project) detachScriptInfoIfNotInferredRoot(path tspath.Path) { - // We might not find the script info in case its not associated with the project any more - // and project graph was not updated (eg delayed update graph in case of files changed/deleted on the disk) - if scriptInfo := p.host.DocumentStore().GetScriptInfoByPath(path); scriptInfo != nil && - (p.kind != KindInferred || !p.isRoot(scriptInfo)) { - scriptInfo.detachFromProject(p) +// GetUnresolvedImports extracts unresolved imports from this project's program. +func (p *Project) GetUnresolvedImports() *collections.Set[string] { + if p.Program == nil { + return nil } -} -func (p *Project) Close() { - p.mu.Lock() - defer p.mu.Unlock() + return p.Program.GetUnresolvedImports() +} - if p.program != nil { - for _, sourceFile := range p.program.GetSourceFiles() { - p.host.DocumentStore().documentRegistry.ReleaseDocument(sourceFile) - // Detach script info if its not root or is root of non inferred project - p.detachScriptInfoIfNotInferredRoot(sourceFile.Path()) - } - p.program.ForEachResolvedProjectReference(func(path tspath.Path, ref *tsoptions.ParsedCommandLine, parent *tsoptions.ParsedCommandLine, index int) { - p.host.ConfigFileRegistry().releaseConfig(path, p) - }) - if p.kind == KindConfigured { - p.host.ConfigFileRegistry().releaseConfig(p.configFilePath, p) - } - p.program = nil +// 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 } - if p.kind == KindInferred { - // Release root script infos for inferred projects. - for path := range p.rootFileNames.Keys() { - if info := p.host.DocumentStore().GetScriptInfoByPath(path); info != nil { - info.detachFromProject(p) - } - } + typeAcquisition := p.GetTypeAcquisition() + if typeAcquisition == nil || !typeAcquisition.Enable.IsTrue() { + return false } - p.rootFileNames = nil - p.rootJSFileCount = 0 - p.parsedCommandLine = nil - p.programConfig = nil - p.checkerPool = nil - p.unresolvedImportsPerFile = nil - p.unresolvedImports = nil - p.typingsInfo = nil - p.typingFiles = nil - // Clean up file watchers waiting for missing files - client := p.Client() - if p.host.IsWatchEnabled() && client != nil { - ctx := context.Background() - p.failedLookupsWatch.update(ctx, nil) - p.affectingLocationsWatch.update(ctx, nil) - p.typingsFilesWatch.update(ctx, nil) - p.typingsDirectoryWatch.update(ctx, nil) + if p.installedTypingsInfo == nil || p.ProgramLastUpdate == snapshotID && p.ProgramUpdateKind == ProgramUpdateKindNewFiles { + return true } -} -func (p *Project) isClosed() bool { - return p.rootFileNames == nil + return !p.installedTypingsInfo.Equals(p.ComputeTypingsInfo()) } -func formatFileList(files []string, linePrefix string, groupSuffix string) string { - var builder strings.Builder - length := len(groupSuffix) - for _, file := range files { - length += len(file) + len(linePrefix) + 1 +func (p *Project) ComputeTypingsInfo() ata.TypingsInfo { + return ata.TypingsInfo{ + CompilerOptions: p.CommandLine.CompilerOptions(), + TypeAcquisition: p.GetTypeAcquisition(), + UnresolvedImports: p.GetUnresolvedImports(), } - builder.Grow(length) - for _, file := range files { - builder.WriteString(linePrefix) - builder.WriteString(file) - builder.WriteRune('\n') - } - builder.WriteString(groupSuffix) - return builder.String() } diff --git a/internal/project/project_stringer_generated.go b/internal/project/project_stringer_generated.go index e9ae7b5e0a..4c17d1ebe8 100644 --- a/internal/project/project_stringer_generated.go +++ b/internal/project/project_stringer_generated.go @@ -1,4 +1,4 @@ -// Code generated by "stringer -type=Kind -output=project_stringer_generated.go"; DO NOT EDIT. +// Code generated by "stringer -type=Kind -trimprefix=Kind -output=project_stringer_generated.go"; DO NOT EDIT. package project @@ -10,13 +10,11 @@ func _() { var x [1]struct{} _ = x[KindInferred-0] _ = x[KindConfigured-1] - _ = x[KindAutoImportProvider-2] - _ = x[KindAuxiliary-3] } -const _Kind_name = "KindInferredKindConfiguredKindAutoImportProviderKindAuxiliary" +const _Kind_name = "InferredConfigured" -var _Kind_index = [...]uint8{0, 12, 26, 48, 61} +var _Kind_index = [...]uint8{0, 8, 18} func (i Kind) String() string { if i < 0 || i >= Kind(len(_Kind_index)-1) { diff --git a/internal/project/projectcollection.go b/internal/project/projectcollection.go new file mode 100644 index 0000000000..fcade548c0 --- /dev/null +++ b/internal/project/projectcollection.go @@ -0,0 +1,253 @@ +package project + +import ( + "cmp" + "slices" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" +) + +type ProjectCollection struct { + toPath func(fileName string) tspath.Path + configFileRegistry *ConfigFileRegistry + // fileDefaultProjects is a map of file paths to the config file path (the key + // into `configuredProjects`) of the default project for that file. If the file + // belongs to the inferred project, the value is `inferredProjectName`. This map + // contains quick lookups for only the associations discovered during the latest + // snapshot update. + fileDefaultProjects map[tspath.Path]tspath.Path + // configuredProjects is the set of loaded projects associated with a tsconfig + // file, keyed by the config file path. + configuredProjects map[tspath.Path]*Project + // inferredProject is a fallback project that is used when no configured + // project can be found for an open file. + inferredProject *Project + // apiOpenedProjects is the set of projects that should be kept open for + // API clients. + apiOpenedProjects map[tspath.Path]struct{} +} + +func (c *ProjectCollection) ConfiguredProject(path tspath.Path) *Project { + return c.configuredProjects[path] +} + +func (c *ProjectCollection) GetProjectByPath(projectPath tspath.Path) *Project { + // First check if it's a configured project + if project, ok := c.configuredProjects[projectPath]; ok { + return project + } + + // Check if it's the inferred project path + if projectPath == inferredProjectName { + return c.inferredProject + } + + return nil +} + +func (c *ProjectCollection) ConfiguredProjects() []*Project { + projects := make([]*Project, 0, len(c.configuredProjects)) + c.fillConfiguredProjects(&projects) + return projects +} + +func (c *ProjectCollection) fillConfiguredProjects(projects *[]*Project) { + for _, p := range c.configuredProjects { + *projects = append(*projects, p) + } + slices.SortFunc(*projects, func(a, b *Project) int { + return cmp.Compare(a.Name(), b.Name()) + }) +} + +func (c *ProjectCollection) Projects() []*Project { + if c.inferredProject == nil { + return c.ConfiguredProjects() + } + projects := make([]*Project, 0, len(c.configuredProjects)+1) + c.fillConfiguredProjects(&projects) + projects = append(projects, c.inferredProject) + return projects +} + +func (c *ProjectCollection) InferredProject() *Project { + return c.inferredProject +} + +// !!! result could be cached +func (c *ProjectCollection) GetDefaultProject(fileName string, path tspath.Path) *Project { + if result, ok := c.fileDefaultProjects[path]; ok { + if result == inferredProjectName { + return c.inferredProject + } + return c.configuredProjects[result] + } + + var ( + containingProjects []*Project + firstConfiguredProject *Project + firstNonSourceOfProjectReferenceRedirect *Project + multipleDirectInclusions bool + ) + for _, p := range c.ConfiguredProjects() { + if p.containsFile(path) { + containingProjects = append(containingProjects, p) + if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) { + if firstNonSourceOfProjectReferenceRedirect == nil { + firstNonSourceOfProjectReferenceRedirect = p + } else { + multipleDirectInclusions = true + } + } + if firstConfiguredProject == nil { + firstConfiguredProject = p + } + } + } + if len(containingProjects) == 1 { + return containingProjects[0] + } + if len(containingProjects) == 0 { + if c.inferredProject != nil && c.inferredProject.containsFile(path) { + return c.inferredProject + } + return nil + } + if !multipleDirectInclusions { + if firstNonSourceOfProjectReferenceRedirect != nil { + // Multiple projects include the file, but only one is a direct inclusion. + return firstNonSourceOfProjectReferenceRedirect + } + // Multiple projects include the file, and none are direct inclusions. + return firstConfiguredProject + } + // Multiple projects include the file directly. + if defaultProject := c.findDefaultConfiguredProject(fileName, path); defaultProject != nil { + return defaultProject + } + return firstConfiguredProject +} + +func (c *ProjectCollection) findDefaultConfiguredProject(fileName string, path tspath.Path) *Project { + if configFileName := c.configFileRegistry.GetConfigFileName(path); configFileName != "" { + return c.findDefaultConfiguredProjectWorker(fileName, path, configFileName, nil, nil) + } + return nil +} + +func (c *ProjectCollection) findDefaultConfiguredProjectWorker(fileName string, path tspath.Path, configFileName string, visited *collections.SyncSet[*Project], fallback *Project) *Project { + configFilePath := c.toPath(configFileName) + project, ok := c.configuredProjects[configFilePath] + if !ok { + return nil + } + if visited == nil { + visited = &collections.SyncSet[*Project]{} + } + + // Look in the config's project and its references recursively. + search := core.BreadthFirstSearchParallelEx( + project, + func(project *Project) []*Project { + if project.CommandLine == nil { + return nil + } + return core.Map(project.CommandLine.ResolvedProjectReferencePaths(), func(configFileName string) *Project { + return c.configuredProjects[c.toPath(configFileName)] + }) + }, + func(project *Project) (isResult bool, stop bool) { + if project.containsFile(path) { + return true, !project.IsSourceFromProjectReference(path) + } + return false, false + }, + core.BreadthFirstSearchOptions[*Project]{ + Visited: visited, + }, + ) + + if search.Stopped { + // If we found a project that directly contains the file, return it. + return search.Path[0] + } + if len(search.Path) > 0 && fallback == nil { + // If we found a project that contains the file, but it is a source from + // a project reference, record it as a fallback. + fallback = search.Path[0] + } + + // Look for tsconfig.json files higher up the directory tree and do the same. This handles + // the common case where a higher-level "solution" tsconfig.json contains all projects in a + // workspace. + if config := c.configFileRegistry.GetConfig(path); config != nil && config.CompilerOptions().DisableSolutionSearching.IsTrue() { + return fallback + } + if ancestorConfigName := c.configFileRegistry.GetAncestorConfigFileName(path, configFileName); ancestorConfigName != "" { + return c.findDefaultConfiguredProjectWorker(fileName, path, ancestorConfigName, visited, fallback) + } + return fallback +} + +// clone creates a shallow copy of the project collection. +func (c *ProjectCollection) clone() *ProjectCollection { + return &ProjectCollection{ + toPath: c.toPath, + configuredProjects: c.configuredProjects, + inferredProject: c.inferredProject, + fileDefaultProjects: c.fileDefaultProjects, + } +} + +// findDefaultConfiguredProjectFromProgramInclusion finds the default configured project for a file +// based on the file's inclusion in existing projects. The projects should be sorted, as ties will +// be broken by slice order. `getProject` should return a project with an up-to-date program. +// Along with the resulting project path, a boolean is returned indicating whether there were multiple +// direct inclusions of the file in different projects, indicating that the caller may want to perform +// additional logic to determine the best project. +func findDefaultConfiguredProjectFromProgramInclusion( + fileName string, + path tspath.Path, + projectPaths []tspath.Path, + getProject func(tspath.Path) *Project, +) (result tspath.Path, multipleCandidates bool) { + var ( + containingProjects []tspath.Path + firstConfiguredProject tspath.Path + firstNonSourceOfProjectReferenceRedirect tspath.Path + multipleDirectInclusions bool + ) + + for _, projectPath := range projectPaths { + p := getProject(projectPath) + if p.containsFile(path) { + containingProjects = append(containingProjects, projectPath) + if !multipleDirectInclusions && !p.IsSourceFromProjectReference(path) { + if firstNonSourceOfProjectReferenceRedirect == "" { + firstNonSourceOfProjectReferenceRedirect = projectPath + } else { + multipleDirectInclusions = true + } + } + if firstConfiguredProject == "" { + firstConfiguredProject = projectPath + } + } + } + + if len(containingProjects) == 1 { + return containingProjects[0], false + } + if !multipleDirectInclusions { + if firstNonSourceOfProjectReferenceRedirect != "" { + // Multiple projects include the file, but only one is a direct inclusion. + return firstNonSourceOfProjectReferenceRedirect, false + } + // Multiple projects include the file, and none are direct inclusions. + return firstConfiguredProject, false + } + // Multiple projects include the file directly. + return firstConfiguredProject, true +} diff --git a/internal/project/projectcollectionbuilder.go b/internal/project/projectcollectionbuilder.go new file mode 100644 index 0000000000..85a55df10b --- /dev/null +++ b/internal/project/projectcollectionbuilder.go @@ -0,0 +1,846 @@ +package project + +import ( + "context" + "fmt" + "maps" + "slices" + "time" + + "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project/dirty" + "github.com/microsoft/typescript-go/internal/project/logging" + "github.com/microsoft/typescript-go/internal/tsoptions" + "github.com/microsoft/typescript-go/internal/tspath" +) + +type projectLoadKind int + +const ( + // Project is not created or updated, only looked up in cache + projectLoadKindFind projectLoadKind = iota + // Project is created and then its graph is updated + projectLoadKindCreate +) + +type projectCollectionBuilder struct { + sessionOptions *SessionOptions + parseCache *ParseCache + extendedConfigCache *extendedConfigCache + + ctx context.Context + fs *snapshotFSBuilder + base *ProjectCollection + compilerOptionsForInferredProjects *core.CompilerOptions + configFileRegistryBuilder *configFileRegistryBuilder + + newSnapshotID uint64 + programStructureChanged bool + fileDefaultProjects map[tspath.Path]tspath.Path + configuredProjects *dirty.SyncMap[tspath.Path, *Project] + inferredProject *dirty.Box[*Project] + + apiOpenedProjects map[tspath.Path]struct{} +} + +func newProjectCollectionBuilder( + ctx context.Context, + newSnapshotID uint64, + fs *snapshotFSBuilder, + oldProjectCollection *ProjectCollection, + oldConfigFileRegistry *ConfigFileRegistry, + oldAPIOpenedProjects map[tspath.Path]struct{}, + compilerOptionsForInferredProjects *core.CompilerOptions, + sessionOptions *SessionOptions, + parseCache *ParseCache, + extendedConfigCache *extendedConfigCache, +) *projectCollectionBuilder { + return &projectCollectionBuilder{ + ctx: ctx, + fs: fs, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + sessionOptions: sessionOptions, + parseCache: parseCache, + extendedConfigCache: extendedConfigCache, + base: oldProjectCollection, + configFileRegistryBuilder: newConfigFileRegistryBuilder(fs, oldConfigFileRegistry, extendedConfigCache, sessionOptions, nil), + newSnapshotID: newSnapshotID, + configuredProjects: dirty.NewSyncMap(oldProjectCollection.configuredProjects, nil), + inferredProject: dirty.NewBox(oldProjectCollection.inferredProject), + apiOpenedProjects: maps.Clone(oldAPIOpenedProjects), + } +} + +func (b *projectCollectionBuilder) Finalize(logger *logging.LogTree) (*ProjectCollection, *ConfigFileRegistry) { + var changed bool + newProjectCollection := b.base + ensureCloned := func() { + if !changed { + newProjectCollection = newProjectCollection.clone() + changed = true + } + } + + if configuredProjects, configuredProjectsChanged := b.configuredProjects.Finalize(); configuredProjectsChanged { + ensureCloned() + newProjectCollection.configuredProjects = configuredProjects + } + + if !changed && !maps.Equal(b.fileDefaultProjects, b.base.fileDefaultProjects) { + ensureCloned() + newProjectCollection.fileDefaultProjects = b.fileDefaultProjects + } + + if newInferredProject, inferredProjectChanged := b.inferredProject.Finalize(); inferredProjectChanged { + ensureCloned() + newProjectCollection.inferredProject = newInferredProject + } + + configFileRegistry := b.configFileRegistryBuilder.Finalize() + newProjectCollection.configFileRegistry = configFileRegistry + return newProjectCollection, configFileRegistry +} + +func (b *projectCollectionBuilder) forEachProject(fn func(entry dirty.Value[*Project]) bool) { + keepGoing := true + b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool { + keepGoing = fn(entry) + return keepGoing + }) + if !keepGoing { + return + } + if b.inferredProject.Value() != nil { + fn(b.inferredProject) + } +} + +func (b *projectCollectionBuilder) HandleAPIRequest(apiRequest *APISnapshotRequest, logger *logging.LogTree) error { + var projectsToClose map[tspath.Path]struct{} + if apiRequest.CloseProjects != nil { + projectsToClose = maps.Clone(apiRequest.CloseProjects.M) + for projectPath := range apiRequest.CloseProjects.Keys() { + delete(b.apiOpenedProjects, projectPath) + } + } + + if apiRequest.OpenProjects != nil { + for configFileName := range apiRequest.OpenProjects.Keys() { + configPath := b.toPath(configFileName) + if entry := b.findOrCreateProject(configFileName, configPath, projectLoadKindCreate, logger); entry != nil { + if b.apiOpenedProjects == nil { + b.apiOpenedProjects = make(map[tspath.Path]struct{}) + } + b.apiOpenedProjects[configPath] = struct{}{} + b.updateProgram(entry, logger) + } else { + return fmt.Errorf("project not found for open: %s", configFileName) + } + } + } + + if apiRequest.UpdateProjects != nil { + for configPath := range apiRequest.UpdateProjects.Keys() { + if entry, ok := b.configuredProjects.Load(configPath); ok { + b.updateProgram(entry, logger) + } else { + return fmt.Errorf("project not found for update: %s", configPath) + } + } + } + + for _, overlay := range b.fs.overlays { + if entry := b.findDefaultConfiguredProject(overlay.FileName(), b.toPath(overlay.FileName())); entry != nil { + delete(projectsToClose, entry.Value().configFilePath) + } + } + + for projectPath := range projectsToClose { + if entry, ok := b.configuredProjects.Load(projectPath); ok { + b.deleteConfiguredProject(entry, logger) + } + } + + return nil +} + +func (b *projectCollectionBuilder) DidChangeFiles(summary FileChangeSummary, logger *logging.LogTree) { + changedFiles := make([]tspath.Path, 0, len(summary.Closed)+summary.Changed.Len()) + for uri, hash := range summary.Closed { + fileName := uri.FileName() + path := b.toPath(fileName) + if fh := b.fs.GetFileByPath(fileName, path); fh == nil || fh.Hash() != hash { + changedFiles = append(changedFiles, path) + } + } + for uri := range summary.Changed.Keys() { + fileName := uri.FileName() + path := b.toPath(fileName) + changedFiles = append(changedFiles, path) + } + + configChangeLogger := logger.Fork("Checking for changes affecting config files") + configChangeResult := b.configFileRegistryBuilder.DidChangeFiles(summary, configChangeLogger) + logChangeFileResult(configChangeResult, configChangeLogger) + + b.forEachProject(func(entry dirty.Value[*Project]) bool { + // Handle closed and changed files + b.markFilesChanged(entry, changedFiles, lsproto.FileChangeTypeChanged, logger) + if entry.Value().Kind == KindInferred && len(summary.Closed) > 0 { + rootFilesMap := entry.Value().CommandLine.FileNamesByPath() + newRootFiles := entry.Value().CommandLine.FileNames() + for uri := range summary.Closed { + fileName := uri.FileName() + path := b.toPath(fileName) + if _, ok := rootFilesMap[path]; ok { + newRootFiles = slices.Delete(newRootFiles, slices.Index(newRootFiles, fileName), slices.Index(newRootFiles, fileName)+1) + } + } + b.updateInferredProjectRoots(newRootFiles, logger) + } + + // Handle deleted files + if summary.Deleted.Len() > 0 { + deletedPaths := make([]tspath.Path, 0, summary.Deleted.Len()) + for uri := range summary.Deleted.Keys() { + fileName := uri.FileName() + path := b.toPath(fileName) + deletedPaths = append(deletedPaths, path) + } + b.markFilesChanged(entry, deletedPaths, lsproto.FileChangeTypeDeleted, logger) + } + + // Handle created files + if summary.Created.Len() > 0 { + createdPaths := make([]tspath.Path, 0, summary.Created.Len()) + for uri := range summary.Created.Keys() { + fileName := uri.FileName() + path := b.toPath(fileName) + createdPaths = append(createdPaths, path) + } + b.markFilesChanged(entry, createdPaths, lsproto.FileChangeTypeCreated, logger) + } + + return true + }) + + // Handle opened file + if summary.Opened != "" { + fileName := summary.Opened.FileName() + path := b.toPath(fileName) + var toRemoveProjects collections.Set[tspath.Path] + openFileResult := b.ensureConfiguredProjectAndAncestorsForOpenFile(fileName, path, logger) + b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool { + toRemoveProjects.Add(entry.Value().configFilePath) + b.updateProgram(entry, logger) + return true + }) + + var inferredProjectFiles []string + for _, overlay := range b.fs.overlays { + if p := b.findDefaultConfiguredProject(overlay.FileName(), b.toPath(overlay.FileName())); p != nil { + toRemoveProjects.Delete(p.Value().configFilePath) + } else { + inferredProjectFiles = append(inferredProjectFiles, overlay.FileName()) + } + } + + for projectPath := range toRemoveProjects.Keys() { + if openFileResult.retain.Has(projectPath) { + continue + } + if _, ok := b.apiOpenedProjects[projectPath]; ok { + continue + } + if p, ok := b.configuredProjects.Load(projectPath); ok { + b.deleteConfiguredProject(p, logger) + } + } + b.updateInferredProjectRoots(inferredProjectFiles, logger) + b.configFileRegistryBuilder.Cleanup() + } + + b.programStructureChanged = b.markProjectsAffectedByConfigChanges(configChangeResult, logger) +} + +func logChangeFileResult(result changeFileResult, logger *logging.LogTree) { + if len(result.affectedProjects) > 0 { + logger.Logf("Config file change affected projects: %v", slices.Collect(maps.Keys(result.affectedProjects))) + } + if len(result.affectedFiles) > 0 { + logger.Logf("Config file change affected config file lookups for %d files", len(result.affectedFiles)) + } +} + +func (b *projectCollectionBuilder) DidRequestFile(uri lsproto.DocumentUri, logger *logging.LogTree) { + startTime := time.Now() + fileName := uri.FileName() + hasChanges := b.programStructureChanged + + // See if we can find a default project without updating a bunch of stuff. + path := b.toPath(fileName) + if result := b.findDefaultProject(fileName, path); result != nil { + hasChanges = b.updateProgram(result, logger) || hasChanges + if result.Value() != nil { + return + } + } + + // Make sure all projects we know about are up to date... + b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool { + hasChanges = b.updateProgram(entry, logger) || hasChanges + return true + }) + if hasChanges { + // If the structure of other projects changed, we might need to move files + // in/out of the inferred project. + var inferredProjectFiles []string + for path, overlay := range b.fs.overlays { + if b.findDefaultConfiguredProject(overlay.FileName(), path) == nil { + inferredProjectFiles = append(inferredProjectFiles, overlay.FileName()) + } + } + if len(inferredProjectFiles) > 0 { + b.updateInferredProjectRoots(inferredProjectFiles, logger) + } + } + + if b.inferredProject.Value() != nil { + b.updateProgram(b.inferredProject, logger) + } + + // At this point we should be able to find the default project for the file without + // creating anything else. Initially, I verified that and panicked if nothing was found, + // but that panic was getting triggered by fourslash infrastructure when it told us to + // open a package.json file. This is something the VS Code client would never do, but + // it seems possible that another client would. There's no point in panicking; we don't + // really even have an error condition until it tries to ask us language questions about + // a non-TS-handleable file. + + if logger != nil { + elapsed := time.Since(startTime) + logger.Log(fmt.Sprintf("Completed file request for %s in %v", fileName, elapsed)) + } +} + +func (b *projectCollectionBuilder) DidUpdateATAState(ataChanges map[tspath.Path]*ATAStateChange, logger *logging.LogTree) { + updateProject := func(project dirty.Value[*Project], ataChange *ATAStateChange) { + project.ChangeIf( + func(p *Project) bool { + if p == nil { + return false + } + // Consistency check: the ATA demands (project options, unresolved imports) of this project + // has not changed since the time the ATA request was dispatched; the change can still be + // applied to this project in its current state. + return ataChange.TypingsInfo.Equals(p.ComputeTypingsInfo()) + }, + func(p *Project) { + // We checked before triggering this change (in Session.triggerATAForUpdatedProjects) that + // the set of typings files is actually different. + p.installedTypingsInfo = ataChange.TypingsInfo + p.typingsFiles = ataChange.TypingsFiles + p.dirty = true + p.dirtyFilePath = "" + }, + ) + } + + for projectPath, ataChange := range ataChanges { + logger.Embed(ataChange.Logs) + if projectPath == inferredProjectName { + updateProject(b.inferredProject, ataChange) + } else if project, ok := b.configuredProjects.Load(projectPath); ok { + updateProject(project, ataChange) + } + + if logger != nil { + logger.Log(fmt.Sprintf("Updated ATA state for project %s", projectPath)) + } + } +} + +func (b *projectCollectionBuilder) markProjectsAffectedByConfigChanges( + configChangeResult changeFileResult, + logger *logging.LogTree, +) bool { + for projectPath := range configChangeResult.affectedProjects { + project, ok := b.configuredProjects.Load(projectPath) + if !ok { + panic(fmt.Sprintf("project %s affected by config change not found", projectPath)) + } + project.ChangeIf( + func(p *Project) bool { return !p.dirty || p.dirtyFilePath != "" }, + func(p *Project) { + p.dirty = true + p.dirtyFilePath = "" + if logger != nil { + logger.Logf("Marking project %s as dirty due to change affecting config", projectPath) + } + }, + ) + } + + // Recompute default projects for open files that now have different config file presence. + var hasChanges bool + for path := range configChangeResult.affectedFiles { + fileName := b.fs.overlays[path].FileName() + _ = b.ensureConfiguredProjectAndAncestorsForOpenFile(fileName, path, logger) + hasChanges = true + } + + return hasChanges +} + +func (b *projectCollectionBuilder) findDefaultProject(fileName string, path tspath.Path) dirty.Value[*Project] { + if configuredProject := b.findDefaultConfiguredProject(fileName, path); configuredProject != nil { + return configuredProject + } + if key, ok := b.fileDefaultProjects[path]; ok && key == inferredProjectName { + return b.inferredProject + } + if inferredProject := b.inferredProject.Value(); inferredProject != nil && inferredProject.containsFile(path) { + if b.fileDefaultProjects == nil { + b.fileDefaultProjects = make(map[tspath.Path]tspath.Path) + } + b.fileDefaultProjects[path] = inferredProjectName + return b.inferredProject + } + return nil +} + +func (b *projectCollectionBuilder) findDefaultConfiguredProject(fileName string, path tspath.Path) *dirty.SyncMapEntry[tspath.Path, *Project] { + // !!! look in fileDefaultProjects first? + // Sort configured projects so we can use a deterministic "first" as a last resort. + var configuredProjectPaths []tspath.Path + configuredProjects := make(map[tspath.Path]*dirty.SyncMapEntry[tspath.Path, *Project]) + b.configuredProjects.Range(func(entry *dirty.SyncMapEntry[tspath.Path, *Project]) bool { + configuredProjectPaths = append(configuredProjectPaths, entry.Key()) + configuredProjects[entry.Key()] = entry + return true + }) + slices.Sort(configuredProjectPaths) + + project, multipleCandidates := findDefaultConfiguredProjectFromProgramInclusion(fileName, path, configuredProjectPaths, func(path tspath.Path) *Project { + return configuredProjects[path].Value() + }) + + if multipleCandidates { + if p := b.findOrCreateDefaultConfiguredProjectForOpenScriptInfo(fileName, path, projectLoadKindFind, nil).project; p != nil { + return p + } + } + + return configuredProjects[project] +} + +func (b *projectCollectionBuilder) ensureConfiguredProjectAndAncestorsForOpenFile(fileName string, path tspath.Path, logger *logging.LogTree) searchResult { + result := b.findOrCreateDefaultConfiguredProjectForOpenScriptInfo(fileName, path, projectLoadKindCreate, logger) + if result.project != nil { + // !!! sheetal todo this later + // // Create ancestor tree for findAllRefs (dont load them right away) + // forEachAncestorProjectLoad( + // info, + // tsconfigProject!, + // ancestor => { + // seenProjects.set(ancestor.project, kind); + // }, + // kind, + // `Creating project possibly referencing default composite project ${defaultProject.getProjectName()} of open file ${info.fileName}`, + // allowDeferredClosed, + // reloadedProjects, + // /*searchOnlyPotentialSolution*/ true, + // delayReloadedConfiguredProjects, + // ); + } + return result +} + +type searchNode struct { + configFileName string + loadKind projectLoadKind + logger *logging.LogTree +} + +type searchResult struct { + project *dirty.SyncMapEntry[tspath.Path, *Project] + retain collections.Set[tspath.Path] +} + +func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectWorker( + fileName string, + path tspath.Path, + configFileName string, + loadKind projectLoadKind, + visited *collections.SyncSet[searchNode], + fallback *searchResult, + logger *logging.LogTree, +) searchResult { + var configs collections.SyncMap[tspath.Path, *tsoptions.ParsedCommandLine] + if visited == nil { + visited = &collections.SyncSet[searchNode]{} + } + + search := core.BreadthFirstSearchParallelEx( + searchNode{configFileName: configFileName, loadKind: loadKind, logger: logger}, + func(node searchNode) []searchNode { + if config, ok := configs.Load(b.toPath(node.configFileName)); ok && len(config.ProjectReferences()) > 0 { + referenceLoadKind := node.loadKind + if config.CompilerOptions().DisableReferencedProjectLoad.IsTrue() { + referenceLoadKind = projectLoadKindFind + } + + var refLogger *logging.LogTree + references := config.ResolvedProjectReferencePaths() + if len(references) > 0 && node.logger != nil { + refLogger = node.logger.Fork(fmt.Sprintf("Searching %d project references of %s", len(references), node.configFileName)) + } + return core.Map(references, func(configFileName string) searchNode { + return searchNode{configFileName: configFileName, loadKind: referenceLoadKind, logger: refLogger.Fork("Searching project reference " + configFileName)} + }) + } + return nil + }, + func(node searchNode) (isResult bool, stop bool) { + configFilePath := b.toPath(node.configFileName) + config := b.configFileRegistryBuilder.findOrAcquireConfigForOpenFile(node.configFileName, configFilePath, path, node.loadKind, node.logger.Fork("Acquiring config for open file")) + if config == nil { + return false, false + } + configs.Store(configFilePath, config) + if len(config.FileNames()) == 0 { + // Likely a solution tsconfig.json - the search will fan out to its references. + node.logger.Log("Project does not contain file (no root files)") + return false, false + } + + if config.CompilerOptions().Composite == core.TSTrue { + // For composite projects, we can get an early negative result. + // !!! what about declaration files in node_modules? wouldn't it be better to + // check project inclusion if the project is already loaded? + if !config.MatchesFileName(fileName) { + node.logger.Log("Project does not contain file (by composite config inclusion)") + return false, false + } + } + + project := b.findOrCreateProject(node.configFileName, configFilePath, node.loadKind, node.logger) + if node.loadKind == projectLoadKindCreate { + // Ensure project is up to date before checking for file inclusion + b.updateProgram(project, node.logger) + } + + if project.Value().containsFile(path) { + isDirectInclusion := !project.Value().IsSourceFromProjectReference(path) + if node.logger != nil { + node.logger.Logf("Project contains file %s", core.IfElse(isDirectInclusion, "directly", "as a source of a referenced project")) + } + return true, isDirectInclusion + } + + node.logger.Log("Project does not contain file") + return false, false + }, + core.BreadthFirstSearchOptions[searchNode]{ + Visited: visited, + PreprocessLevel: func(level *core.BreadthFirstSearchLevel[searchNode]) { + level.Range(func(node searchNode) bool { + if node.loadKind == projectLoadKindFind && level.Has(searchNode{configFileName: node.configFileName, loadKind: projectLoadKindCreate, logger: node.logger}) { + // Remove find requests when a create request for the same project is already present. + level.Delete(node) + } + return true + }) + }, + }, + ) + + var retain collections.Set[tspath.Path] + var project *dirty.SyncMapEntry[tspath.Path, *Project] + if len(search.Path) > 0 { + project, _ = b.configuredProjects.Load(b.toPath(search.Path[0].configFileName)) + // If we found a project, we retain each project along the BFS path. + // We don't want to retain everything we visited since BFS can terminate + // early, and we don't want to retain nondeterministically. + for _, node := range search.Path { + retain.Add(b.toPath(node.configFileName)) + } + } + + if search.Stopped { + // Found a project that directly contains the file. + return searchResult{ + project: project, + retain: retain, + } + } + + if project != nil { + // If we found a project that contains the file, but it is a source from + // a project reference, record it as a fallback. + fallback = &searchResult{ + project: project, + retain: retain, + } + } + + // Look for tsconfig.json files higher up the directory tree and do the same. This handles + // the common case where a higher-level "solution" tsconfig.json contains all projects in a + // workspace. + if config, ok := configs.Load(b.toPath(configFileName)); ok && config.CompilerOptions().DisableSolutionSearching.IsTrue() { + if fallback != nil { + return *fallback + } + } + if ancestorConfigName := b.configFileRegistryBuilder.getAncestorConfigFileName(fileName, path, configFileName, loadKind, logger); ancestorConfigName != "" { + return b.findOrCreateDefaultConfiguredProjectWorker( + fileName, + path, + ancestorConfigName, + loadKind, + visited, + fallback, + logger.Fork("Searching ancestor config file at "+ancestorConfigName), + ) + } + if fallback != nil { + return *fallback + } + // If we didn't find anything, we can retain everything we visited, + // since the whole graph must have been traversed (i.e., the set of + // retained projects is guaranteed to be deterministic). + visited.Range(func(node searchNode) bool { + retain.Add(b.toPath(node.configFileName)) + return true + }) + return searchResult{retain: retain} +} + +func (b *projectCollectionBuilder) findOrCreateDefaultConfiguredProjectForOpenScriptInfo( + fileName string, + path tspath.Path, + loadKind projectLoadKind, + logger *logging.LogTree, +) searchResult { + if key, ok := b.fileDefaultProjects[path]; ok { + if key == inferredProjectName { + // The file belongs to the inferred project + return searchResult{} + } + entry, _ := b.configuredProjects.Load(key) + return searchResult{project: entry} + } + if configFileName := b.configFileRegistryBuilder.getConfigFileNameForFile(fileName, path, loadKind, logger); configFileName != "" { + startTime := time.Now() + result := b.findOrCreateDefaultConfiguredProjectWorker( + fileName, + path, + configFileName, + loadKind, + nil, + nil, + logger.Fork("Searching for default configured project for "+fileName), + ) + if result.project != nil { + if b.fileDefaultProjects == nil { + b.fileDefaultProjects = make(map[tspath.Path]tspath.Path) + } + b.fileDefaultProjects[path] = result.project.Value().configFilePath + } + if logger != nil { + elapsed := time.Since(startTime) + if result.project != nil { + logger.Log(fmt.Sprintf("Found default configured project for %s: %s (in %v)", fileName, result.project.Value().configFileName, elapsed)) + } else { + logger.Log(fmt.Sprintf("No default configured project found for %s (searched in %v)", fileName, elapsed)) + } + } + return result + } + return searchResult{} +} + +func (b *projectCollectionBuilder) findOrCreateProject( + configFileName string, + configFilePath tspath.Path, + loadKind projectLoadKind, + logger *logging.LogTree, +) *dirty.SyncMapEntry[tspath.Path, *Project] { + if loadKind == projectLoadKindFind { + entry, _ := b.configuredProjects.Load(configFilePath) + return entry + } + entry, _ := b.configuredProjects.LoadOrStore(configFilePath, NewConfiguredProject(configFileName, configFilePath, b, logger)) + return entry +} + +func (b *projectCollectionBuilder) toPath(fileName string) tspath.Path { + return tspath.ToPath(fileName, b.sessionOptions.CurrentDirectory, b.fs.fs.UseCaseSensitiveFileNames()) +} + +func (b *projectCollectionBuilder) updateInferredProjectRoots(rootFileNames []string, logger *logging.LogTree) bool { + if len(rootFileNames) == 0 { + if b.inferredProject.Value() != nil { + if logger != nil { + logger.Log("Deleting inferred project") + } + b.inferredProject.Delete() + return true + } + return false + } + + if b.inferredProject.Value() == nil { + b.inferredProject.Set(NewInferredProject(b.sessionOptions.CurrentDirectory, b.compilerOptionsForInferredProjects, rootFileNames, b, logger)) + } else { + newCompilerOptions := b.inferredProject.Value().CommandLine.CompilerOptions() + if b.compilerOptionsForInferredProjects != nil { + newCompilerOptions = b.compilerOptionsForInferredProjects + } + newCommandLine := tsoptions.NewParsedCommandLine(newCompilerOptions, rootFileNames, tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: b.fs.fs.UseCaseSensitiveFileNames(), + CurrentDirectory: b.sessionOptions.CurrentDirectory, + }) + changed := b.inferredProject.ChangeIf( + func(p *Project) bool { + return !maps.Equal(p.CommandLine.FileNamesByPath(), newCommandLine.FileNamesByPath()) + }, + func(p *Project) { + if logger != nil { + logger.Log(fmt.Sprintf("Updating inferred project config with %d root files", len(rootFileNames))) + } + p.CommandLine = newCommandLine + p.dirty = true + p.dirtyFilePath = "" + }, + ) + if !changed { + return false + } + } + return true +} + +// updateProgram updates the program for the given project entry if necessary. It returns +// a boolean indicating whether the update could have caused any structure-affecting changes. +func (b *projectCollectionBuilder) updateProgram(entry dirty.Value[*Project], logger *logging.LogTree) bool { + var updateProgram bool + var filesChanged bool + configFileName := entry.Value().configFileName + startTime := time.Now() + entry.Locked(func(entry dirty.Value[*Project]) { + if entry.Value().Kind == KindConfigured { + commandLine := b.configFileRegistryBuilder.acquireConfigForProject( + entry.Value().configFileName, + entry.Value().configFilePath, + entry.Value(), + logger.Fork("Acquiring config for project"), + ) + if entry.Value().CommandLine != commandLine { + updateProgram = true + if commandLine == nil { + b.deleteConfiguredProject(entry, logger) + filesChanged = true + return + } + entry.Change(func(p *Project) { p.CommandLine = commandLine }) + } + } + if !updateProgram { + updateProgram = entry.Value().dirty + } + if updateProgram { + entry.Change(func(project *Project) { + project.host = newCompilerHost(project.currentDirectory, project, b, logger.Fork("CompilerHost")) + result := project.CreateProgram() + project.Program = result.Program + project.checkerPool = result.CheckerPool + project.ProgramUpdateKind = result.UpdateKind + project.ProgramLastUpdate = b.newSnapshotID + if result.UpdateKind == ProgramUpdateKindNewFiles { + filesChanged = true + if b.sessionOptions.WatchEnabled { + failedLookupsWatch, affectingLocationsWatch := project.CloneWatchers() + project.failedLookupsWatch = failedLookupsWatch + project.affectingLocationsWatch = affectingLocationsWatch + } + } + project.dirty = false + project.dirtyFilePath = "" + }) + } + }) + if updateProgram && logger != nil { + elapsed := time.Since(startTime) + logger.Log(fmt.Sprintf("Program update for %s completed in %v", configFileName, elapsed)) + } + return filesChanged +} + +func (b *projectCollectionBuilder) markFilesChanged(entry dirty.Value[*Project], paths []tspath.Path, changeType lsproto.FileChangeType, logger *logging.LogTree) { + var dirty bool + var dirtyFilePath tspath.Path + entry.ChangeIf( + func(p *Project) bool { + if p.Program == nil || p.dirty && p.dirtyFilePath == "" { + return false + } + + dirtyFilePath = p.dirtyFilePath + for _, path := range paths { + if changeType == lsproto.FileChangeTypeCreated { + if _, ok := p.affectingLocationsWatch.input[path]; ok { + dirty = true + dirtyFilePath = "" + break + } + if _, ok := p.failedLookupsWatch.input[path]; ok { + dirty = true + dirtyFilePath = "" + break + } + } else if p.containsFile(path) { + dirty = true + if changeType == lsproto.FileChangeTypeDeleted { + dirtyFilePath = "" + break + } + if dirtyFilePath == "" { + dirtyFilePath = path + } else if dirtyFilePath != path { + dirtyFilePath = "" + break + } + } + } + return dirty || p.dirtyFilePath != dirtyFilePath + }, + func(p *Project) { + p.dirty = true + p.dirtyFilePath = dirtyFilePath + if logger != nil { + if dirtyFilePath != "" { + logger.Logf("Marking project %s as dirty due to changes in %s", p.configFileName, dirtyFilePath) + } else { + logger.Logf("Marking project %s as dirty", p.configFileName) + } + } + }, + ) +} + +func (b *projectCollectionBuilder) deleteConfiguredProject(project dirty.Value[*Project], logger *logging.LogTree) { + projectPath := project.Value().configFilePath + if logger != nil { + logger.Log("Deleting configured project: " + project.Value().configFileName) + } + if program := project.Value().Program; program != nil { + program.ForEachResolvedProjectReference(func(referencePath tspath.Path, config *tsoptions.ParsedCommandLine, _ *tsoptions.ParsedCommandLine, _ int) { + b.configFileRegistryBuilder.releaseConfigForProject(referencePath, projectPath) + }) + } + b.configFileRegistryBuilder.releaseConfigForProject(projectPath, projectPath) + project.Delete() +} diff --git a/internal/project/projectcollectionbuilder_test.go b/internal/project/projectcollectionbuilder_test.go new file mode 100644 index 0000000000..cd890488dc --- /dev/null +++ b/internal/project/projectcollectionbuilder_test.go @@ -0,0 +1,472 @@ +package project_test + +import ( + "context" + "fmt" + "maps" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestProjectCollectionBuilder(t *testing.T) { + t.Parallel() + + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + t.Run("when project found is solution referencing default project directly", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, "", nil) + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure configured project is found for open file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) != nil) + + // Ensure request can use existing snapshot + _, err := session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + requestSnapshot, requestRelease := session.Snapshot() + defer requestRelease() + assert.Equal(t, requestSnapshot, snapshot) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") != nil, "direct reference should be present") + + // Close the file and open one in an inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should have been released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + }) + + t.Run("when project found is solution referencing default project indirectly", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json", "./tsconfig-indirect2.json"}, "", nil) + applyIndirectProjectFiles(files, 1, "") + applyIndirectProjectFiles(files, 2, "") + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure configured project is found for open file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + srcProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) + assert.Assert(t, srcProject != nil) + + // Verify the default project is the source project + defaultProject := snapshot.GetDefaultProject(uri) + assert.Equal(t, defaultProject, srcProject) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") != nil, "direct reference should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") != nil, "indirect reference should be present") + + // Close the file and open one in an inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect2.json") == nil) + }) + + t.Run("when project found is solution with disableReferencedProjectLoad referencing default project directly", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, `"disableReferencedProjectLoad": true`, nil) + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure no configured project is created due to disableReferencedProjectLoad + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) == nil) + + // Should use inferred project instead + defaultProject := snapshot.GetDefaultProject(uri) + assert.Assert(t, defaultProject != nil) + assert.Equal(t, defaultProject.Kind, project.KindInferred) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil, "direct reference should not be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + }) + + t.Run("when project found is solution referencing default project indirectly through disableReferencedProjectLoad", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json"}, "", nil) + applyIndirectProjectFiles(files, 1, `"disableReferencedProjectLoad": true`) + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure no configured project is created due to disableReferencedProjectLoad in indirect project + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) == nil) + + // Should use inferred project instead + defaultProject := snapshot.GetDefaultProject(uri) + assert.Assert(t, defaultProject != nil) + assert.Equal(t, defaultProject.Kind, project.KindInferred) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") != nil, "solution direct reference should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil, "indirect reference should not be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") == nil) + }) + + t.Run("when project found is solution referencing default project indirectly through disableReferencedProjectLoad in one but without it in another", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-indirect1.json", "./tsconfig-indirect2.json"}, "", nil) + applyIndirectProjectFiles(files, 1, `"disableReferencedProjectLoad": true`) + applyIndirectProjectFiles(files, 2, "") + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure configured project is found through the indirect project without disableReferencedProjectLoad + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + srcProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) + assert.Assert(t, srcProject != nil) + + // Verify the default project is the source project (found through indirect2, not indirect1) + defaultProject := snapshot.GetDefaultProject(uri) + assert.Equal(t, defaultProject, srcProject) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") != nil, "direct reference 1 should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect2.json") != nil, "direct reference 2 should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") != nil, "indirect reference should be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect1.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-indirect2.json") == nil) + }) + + t.Run("when project found is project with own files referencing the file from referenced project", func(t *testing.T) { + t.Parallel() + files := filesForSolutionConfigFile([]string{"./tsconfig-src.json"}, "", []string{`"./own/main.ts"`}) + files["/user/username/projects/myproject/own/main.ts"] = ` + import { foo } from '../src/main'; + foo; + export function bar() {} + ` + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/src/main.ts") + content := files["/user/username/projects/myproject/src/main.ts"].(string) + + // Ensure configured project is found for open file - should load both projects + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + srcProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig-src.json")) + assert.Assert(t, srcProject != nil) + ancestorProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/user/username/projects/myproject/tsconfig.json")) + assert.Assert(t, ancestorProject != nil) + + // Verify the default project is the source project + defaultProject := snapshot.GetDefaultProject(uri) + assert.Equal(t, defaultProject, srcProject) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") != nil, "solution config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") != nil, "direct reference should be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig-src.json") == nil) + }) + + t.Run("when file is not part of first config tree found, looks into ancestor folder and its references to find default project", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/src/projects/project/app/Component-demos.ts": ` + import * as helpers from 'demos/helpers'; + export const demo = () => { + helpers; + } + `, + "/home/src/projects/project/app/Component.ts": `export const Component = () => {}`, + "/home/src/projects/project/app/tsconfig.json": `{ + "compilerOptions": { + "composite": true, + "outDir": "../app-dist/", + }, + "include": ["**/*"], + "exclude": ["**/*-demos.*"], + }`, + "/home/src/projects/project/demos/helpers.ts": "export const foo = 1;", + "/home/src/projects/project/demos/tsconfig.json": `{ + "compilerOptions": { + "composite": true, + "rootDir": "../", + "outDir": "../demos-dist/", + "paths": { + "demos/*": ["./*"], + }, + }, + "include": [ + "**/*", + "../app/**/*-demos.*", + ], + }`, + "/home/src/projects/project/tsconfig.json": `{ + "compilerOptions": { + "outDir": "./dist/", + }, + "references": [ + { "path": "./demos/tsconfig.json" }, + { "path": "./app/tsconfig.json" }, + ], + "files": [] + }`, + } + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///home/src/projects/project/app/Component-demos.ts") + content := files["/home/src/projects/project/app/Component-demos.ts"].(string) + + // Ensure configured project is found for open file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + demoProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/src/projects/project/demos/tsconfig.json")) + assert.Assert(t, demoProject != nil) + + // Verify the default project is the demos project (not the app project that excludes demos files) + defaultProject := snapshot.GetDefaultProject(uri) + assert.Equal(t, defaultProject, demoProject) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/app/tsconfig.json") != nil, "app config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/demos/tsconfig.json") != nil, "demos config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.json") != nil, "solution config should be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/app/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/demos/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.json") == nil) + }) + + t.Run("when dts file is next to ts file and included as root in referenced project", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/src/projects/project/src/index.d.ts": ` + declare global { + interface Window { + electron: ElectronAPI + api: unknown + } + } + `, + "/home/src/projects/project/src/index.ts": `const api = {}`, + "/home/src/projects/project/tsconfig.json": `{ + "include": [ + "src/*.d.ts", + ], + "references": [{ "path": "./tsconfig.node.json" }], + }`, + "/home/src/projects/project/tsconfig.node.json": `{ + "include": ["src/**/*"], + "compilerOptions": { + "composite": true, + }, + }`, + } + session, _ := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///home/src/projects/project/src/index.d.ts") + content := files["/home/src/projects/project/src/index.d.ts"].(string) + + // Ensure configured projects are found for open file + session.DidOpenFile(context.Background(), uri, 1, content, lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + rootProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/src/projects/project/tsconfig.json")) + assert.Assert(t, rootProject != nil) + + // Verify the default project is inferred + defaultProject := snapshot.GetDefaultProject(uri) + assert.Assert(t, defaultProject != nil) + assert.Equal(t, defaultProject.Kind, project.KindInferred) + + // Searched configs should be present while file is open + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.json") != nil, "root config should be present") + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.node.json") != nil, "node config should be present") + + // Close the file and open another one in the inferred project + session.DidCloseFile(context.Background(), uri) + dummyUri := lsproto.DocumentUri("file:///user/username/workspaces/dummy/dummy.ts") + session.DidOpenFile(context.Background(), dummyUri, 1, "const x = 1;", lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Config files should be released + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.json") == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig("/home/src/projects/project/tsconfig.node.json") == nil) + }) +} + +func filesForSolutionConfigFile(solutionRefs []string, compilerOptions string, ownFiles []string) map[string]any { + var compilerOptionsStr string + if compilerOptions != "" { + compilerOptionsStr = fmt.Sprintf(`"compilerOptions": { + %s + },`, compilerOptions) + } + var ownFilesStr string + if len(ownFiles) > 0 { + ownFilesStr = strings.Join(ownFiles, ",") + } + files := map[string]any{ + "/user/username/projects/myproject/tsconfig.json": fmt.Sprintf(`{ + %s + "files": [%s], + "references": [ + %s + ] + }`, compilerOptionsStr, ownFilesStr, strings.Join(core.Map(solutionRefs, func(ref string) string { + return fmt.Sprintf(`{ "path": "%s" }`, ref) + }), ",")), + "/user/username/projects/myproject/tsconfig-src.json": `{ + "compilerOptions": { + "composite": true, + "outDir": "./target", + }, + "include": ["./src/**/*"] + }`, + "/user/username/projects/myproject/src/main.ts": ` + import { foo } from './helpers/functions'; + export { foo };`, + "/user/username/projects/myproject/src/helpers/functions.ts": `export const foo = 1;`, + } + return files +} + +func applyIndirectProjectFiles(files map[string]any, projectIndex int, compilerOptions string) { + maps.Copy(files, filesForIndirectProject(projectIndex, compilerOptions)) +} + +func filesForIndirectProject(projectIndex int, compilerOptions string) map[string]any { + files := map[string]any{ + fmt.Sprintf("/user/username/projects/myproject/tsconfig-indirect%d.json", projectIndex): fmt.Sprintf(`{ + "compilerOptions": { + "composite": true, + "outDir": "./target/", + %s + }, + "files": [ + "./indirect%d/main.ts" + ], + "references": [ + { + "path": "./tsconfig-src.json" + } + ] + }`, compilerOptions, projectIndex), + fmt.Sprintf("/user/username/projects/myproject/indirect%d/main.ts", projectIndex): `export const indirect = 1;`, + } + return files +} diff --git a/internal/project/projectlifetime_test.go b/internal/project/projectlifetime_test.go index 4abe4aeae6..0e824f2d96 100644 --- a/internal/project/projectlifetime_test.go +++ b/internal/project/projectlifetime_test.go @@ -1,10 +1,11 @@ package project_test import ( + "context" "testing" "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" "github.com/microsoft/typescript-go/internal/tspath" "gotest.tools/v3/assert" @@ -15,6 +16,7 @@ func TestProjectLifetime(t *testing.T) { if !bundled.Embedded { t.Skip("bundled files are not embedded") } + t.Run("configured project", func(t *testing.T) { t.Parallel() files := map[string]any{ @@ -52,49 +54,62 @@ func TestProjectLifetime(t *testing.T) { "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, } - service, host := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p1/tsconfig.json")) != nil) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p2/tsconfig.json")) != nil) - assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 2) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p1/tsconfig.json"), true) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p2/tsconfig.json"), true) - - service.CloseFile("/home/projects/TS/p1/src/index.ts") - service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p1/tsconfig.json")) == nil) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p2/tsconfig.json")) != nil) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p3/tsconfig.json")) != nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/index.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")) == nil) - assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 3) - assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 1) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p1/tsconfig.json"), false) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p2/tsconfig.json"), true) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p3/tsconfig.json"), true) - - service.CloseFile("/home/projects/TS/p2/src/index.ts") - service.CloseFile("/home/projects/TS/p3/src/index.ts") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p1/tsconfig.json")) != nil) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p2/tsconfig.json")) == nil) - assert.Assert(t, service.ConfiguredProject(serviceToPath(service, "/home/projects/TS/p3/tsconfig.json")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p2/src/index.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p2/src/x.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p3/src/index.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p3/src/x.ts")) == nil) - assert.Equal(t, len(host.ClientMock.WatchFilesCalls()), 4) - assert.Equal(t, len(host.ClientMock.UnwatchFilesCalls()), 3) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p1/tsconfig.json"), true) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p2/tsconfig.json"), false) - configFileExists(t, service, serviceToPath(service, "/home/projects/TS/p3/tsconfig.json"), false) + session, utils := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + // Open files in two projects + uri1 := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") + uri2 := lsproto.DocumentUri("file:///home/projects/TS/p2/src/index.ts") + session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), uri2, 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) + assert.Equal(t, len(utils.Client().WatchFilesCalls()), 2) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) + + // Close p1 file and open p3 file + session.DidCloseFile(context.Background(), uri1) + uri3 := lsproto.DocumentUri("file:///home/projects/TS/p3/src/index.ts") + session.DidOpenFile(context.Background(), uri3, 1, files["/home/projects/TS/p3/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + // Should still have two projects, but p1 replaced by p3 + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p3/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p3/tsconfig.json")) != nil) + assert.Equal(t, len(utils.Client().WatchFilesCalls()), 3) + assert.Equal(t, len(utils.Client().UnwatchFilesCalls()), 1) + + // Close p2 and p3 files, open p1 file again + session.DidCloseFile(context.Background(), uri2) + session.DidCloseFile(context.Background(), uri3) + session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.WaitForBackgroundTasks() + // Should have one project (p1) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p2/tsconfig.json")) == nil) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p3/tsconfig.json")) == nil) + assert.Equal(t, len(utils.Client().WatchFilesCalls()), 4) + assert.Equal(t, len(utils.Client().UnwatchFilesCalls()), 3) }) - t.Run("inferred projects", func(t *testing.T) { + t.Run("unrooted inferred projects", func(t *testing.T) { t.Parallel() files := map[string]any{ "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, @@ -107,70 +122,99 @@ func TestProjectLifetime(t *testing.T) { "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, } - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") - assert.Equal(t, len(service.Projects()), 2) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p1")) != nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p2")) != nil) - - service.CloseFile("/home/projects/TS/p1/src/index.ts") - service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p3") - assert.Equal(t, len(service.Projects()), 2) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p1")) == nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p2")) != nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p3")) != nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/index.ts")) == nil) - - service.CloseFile("/home/projects/TS/p2/src/index.ts") - service.CloseFile("/home/projects/TS/p3/src/index.ts") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p1") - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p1")) != nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p2")) == nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p3")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p2/src/index.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p3/src/index.ts")) == nil) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + // Open files without workspace roots (empty string) - should create single inferred project + uri1 := lsproto.DocumentUri("file:///home/projects/TS/p1/src/index.ts") + uri2 := lsproto.DocumentUri("file:///home/projects/TS/p2/src/index.ts") + session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), uri2, 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + // Should have one inferred project + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Close p1 file and open p3 file + session.DidCloseFile(context.Background(), uri1) + uri3 := lsproto.DocumentUri("file:///home/projects/TS/p3/src/index.ts") + session.DidOpenFile(context.Background(), uri3, 1, files["/home/projects/TS/p3/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + // Should still have one inferred project + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Close p2 and p3 files, open p1 file again + session.DidCloseFile(context.Background(), uri2) + session.DidCloseFile(context.Background(), uri3) + session.DidOpenFile(context.Background(), uri1, 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + // Should still have one inferred project + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) }) - t.Run("unrooted inferred projects", func(t *testing.T) { + t.Run("file moves from inferred to configured project", func(t *testing.T) { t.Parallel() files := map[string]any{ - "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, - "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, - "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, - "/home/projects/TS/p2/src/index.ts": `import { x } from "./x";`, - "/home/projects/TS/p2/src/x.ts": `export const x = 1;`, - "/home/projects/TS/p2/config.ts": `let x = 1, y = 2;`, - "/home/projects/TS/p3/src/index.ts": `import { x } from "./x";`, - "/home/projects/TS/p3/src/x.ts": `export const x = 1;`, - "/home/projects/TS/p3/config.ts": `let x = 1, y = 2;`, + "/home/projects/ts/foo.ts": `export const foo = 1;`, + "/home/projects/ts/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["main.ts"] + }`, + "/home/projects/ts/p1/main.ts": `import { foo } from "../foo"; console.log(foo);`, } - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) - - service.CloseFile("/home/projects/TS/p1/src/index.ts") - service.OpenFile("/home/projects/TS/p3/src/index.ts", files["/home/projects/TS/p3/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/index.ts")) == nil) - - service.CloseFile("/home/projects/TS/p2/src/index.ts") - service.CloseFile("/home/projects/TS/p3/src/index.ts") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"].(string), core.ScriptKindTS, "") - assert.Assert(t, service.InferredProject(tspath.Path("")) != nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p2/src/index.ts")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p3/src/index.ts")) == nil) - - service.CloseFile("/home/projects/TS/p1/src/index.ts") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"].(string), core.ScriptKindTS, "/home/projects/TS/p2") - assert.Equal(t, len(service.Projects()), 1) - assert.Assert(t, service.InferredProject(tspath.Path("")) == nil) - assert.Assert(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/index.ts")) == nil) - assert.Assert(t, service.InferredProject(serviceToPath(service, "/home/projects/TS/p2")) != nil) + session, _ := projecttestutil.Setup(files) + + // Open foo.ts first - should create inferred project since no tsconfig found initially + fooUri := lsproto.DocumentUri("file:///home/projects/ts/foo.ts") + session.DidOpenFile(context.Background(), fooUri, 1, files["/home/projects/ts/foo.ts"].(string), lsproto.LanguageKindTypeScript) + + // Should have one inferred project + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) == nil) + + // Now open main.ts - should trigger discovery of tsconfig.json and move foo.ts to configured project + mainUri := lsproto.DocumentUri("file:///home/projects/ts/p1/main.ts") + session.DidOpenFile(context.Background(), mainUri, 1, files["/home/projects/ts/p1/main.ts"].(string), lsproto.LanguageKindTypeScript) + + // Should now have one configured project and no inferred project + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() == nil) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + + // Config file should be present + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + + // Close main.ts - configured project should remain because foo.ts is still open + session.DidCloseFile(context.Background(), mainUri) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) + + // Close foo.ts - configured project should be retained until next file open + session.DidCloseFile(context.Background(), fooUri) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ConfigFileRegistry.GetConfig(tspath.Path("/home/projects/ts/p1/tsconfig.json")) != nil) }) } diff --git a/internal/project/projectreferencesprogram_test.go b/internal/project/projectreferencesprogram_test.go index 1d79d51c10..df4062bfb7 100644 --- a/internal/project/projectreferencesprogram_test.go +++ b/internal/project/projectreferencesprogram_test.go @@ -1,6 +1,7 @@ package project_test import ( + "context" "fmt" "testing" @@ -24,19 +25,24 @@ func TestProjectReferencesProgram(t *testing.T) { t.Run("program for referenced project", func(t *testing.T) { t.Parallel() files := filesForReferencedProjectProgram(false) - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/user/username/projects/myproject/main/main.ts", files["/user/username/projects/myproject/main/main.ts"].(string), core.ScriptKindTS, "/user/username/projects/myproject") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - scriptInfo := service.DocumentStore().GetScriptInfoByPath("/user/username/projects/myproject/dependency/fns.ts") - assert.Assert(t, scriptInfo != nil) - dtsScriptInfo := service.DocumentStore().GetScriptInfoByPath("/user/username/projects/myproject/decls/fns.d.ts") - assert.Assert(t, dtsScriptInfo == nil) - file := p.CurrentProgram().GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/dependency/fns.ts")) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/main/main.ts") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/myproject/main/main.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + file := p.Program.GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/dependency/fns.ts")) assert.Assert(t, file != nil) - dtsFile := p.CurrentProgram().GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/decls/fns.d.ts")) + dtsFile := p.Program.GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/decls/fns.d.ts")) assert.Assert(t, dtsFile == nil) }) @@ -50,191 +56,245 @@ func TestProjectReferencesProgram(t *testing.T) { export declare function fn4(): void; export declare function fn5(): void; ` - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/user/username/projects/myproject/main/main.ts", files["/user/username/projects/myproject/main/main.ts"].(string), core.ScriptKindTS, "/user/username/projects/myproject") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - scriptInfo := service.DocumentStore().GetScriptInfoByPath("/user/username/projects/myproject/dependency/fns.ts") - assert.Assert(t, scriptInfo == nil) - dtsScriptInfo := service.DocumentStore().GetScriptInfoByPath("/user/username/projects/myproject/decls/fns.d.ts") - assert.Assert(t, dtsScriptInfo != nil) - file := p.CurrentProgram().GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/dependency/fns.ts")) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/main/main.ts") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/myproject/main/main.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + file := p.Program.GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/dependency/fns.ts")) assert.Assert(t, file == nil) - dtsFile := p.CurrentProgram().GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/decls/fns.d.ts")) + dtsFile := p.Program.GetSourceFileByPath(tspath.Path("/user/username/projects/myproject/decls/fns.d.ts")) assert.Assert(t, dtsFile != nil) }) t.Run("references through symlink with index and typings", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferences(false, "") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink with index and typings with preserveSymlinks", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferences(true, "") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink with index and typings scoped package", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferences(false, "@issue/") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink with index and typings with scoped package preserveSymlinks", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferences(true, "@issue/") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink referencing from subFolder", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferencesInSubfolder(false, "") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink referencing from subFolder with preserveSymlinks", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferencesInSubfolder(true, "") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink referencing from subFolder scoped package", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferencesInSubfolder(false, "@issue/") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("references through symlink referencing from subFolder with scoped package preserveSymlinks", func(t *testing.T) { t.Parallel() files, aTest, bFoo, bBar := filesForSymlinkReferencesInSubfolder(true, "@issue/") - service, _ := projecttestutil.Setup(files, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile(aTest, files[aTest].(string), core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - fooInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bFoo)) - assert.Assert(t, fooInfo != nil) - barInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, bBar)) - assert.Assert(t, barInfo != nil) - fooFile := p.CurrentProgram().GetSourceFile(bFoo) + session, _ := projecttestutil.Setup(files) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + uri := lsproto.DocumentUri("file://" + aTest) + session.DidOpenFile(context.Background(), uri, 1, files[aTest].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + projects := snapshot.ProjectCollection.Projects() + p := projects[0] + assert.Equal(t, p.Kind, project.KindConfigured) + + fooFile := p.Program.GetSourceFile(bFoo) assert.Assert(t, fooFile != nil) - barFile := p.CurrentProgram().GetSourceFile(bBar) + barFile := p.Program.GetSourceFile(bBar) assert.Assert(t, barFile != nil) }) t.Run("when new file is added to referenced project", func(t *testing.T) { t.Parallel() files := filesForReferencedProjectProgram(false) - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/user/username/projects/myproject/main/main.ts", files["/user/username/projects/myproject/main/main.ts"].(string), core.ScriptKindTS, "/user/username/projects/myproject") - assert.Equal(t, len(service.Projects()), 1) - project := service.Projects()[0] - programBefore := project.GetProgram() - err := host.FS().WriteFile("/user/username/projects/myproject/dependency/fns2.ts", `export const x = 2;`, false) + session, utils := projecttestutil.Setup(files) + uri := lsproto.DocumentUri("file:///user/username/projects/myproject/main/main.ts") + session.DidOpenFile(context.Background(), uri, 1, files["/user/username/projects/myproject/main/main.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + programBefore := snapshot.ProjectCollection.Projects()[0].Program + + err := utils.FS().WriteFile("/user/username/projects/myproject/dependency/fns2.ts", `export const x = 2;`, false) assert.NilError(t, err) - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ { - Type: lsproto.FileChangeTypeChanged, + Type: lsproto.FileChangeTypeCreated, Uri: "file:///user/username/projects/myproject/dependency/fns2.ts", }, - })) - assert.Check(t, project.GetProgram() != programBefore) + }) + + _, err = session.GetLanguageService(context.Background(), uri) + assert.NilError(t, err) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Check(t, snapshot.ProjectCollection.Projects()[0].Program != programBefore) }) } diff --git a/internal/project/refcounting_test.go b/internal/project/refcounting_test.go new file mode 100644 index 0000000000..eef05a7898 --- /dev/null +++ b/internal/project/refcounting_test.go @@ -0,0 +1,135 @@ +package project + +import ( + "context" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestRefCountingCaches(t *testing.T) { + t.Parallel() + + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + setup := func(files map[string]any) *Session { + fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) + session := NewSession(&SessionInit{ + Options: &SessionOptions{ + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: "/home/src/Library/Caches/typescript", + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: false, + LoggingEnabled: false, + }, + FS: fs, + }) + return session + } + + t.Run("parseCache", func(t *testing.T) { + t.Parallel() + + files := map[string]any{ + "/user/username/projects/myproject/src/main.ts": "const x = 1;", + "/user/username/projects/myproject/src/utils.ts": "export function util() {}", + } + + t.Run("reuse unchanged file", func(t *testing.T) { + t.Parallel() + + session := setup(files) + session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 1, files["/user/username/projects/myproject/src/utils.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + program := snapshot.ProjectCollection.InferredProject().Program + main := program.GetSourceFile("/user/username/projects/myproject/src/main.ts") + utils := program.GetSourceFile("/user/username/projects/myproject/src/utils.ts") + mainEntry, _ := session.parseCache.entries.Load(newParseCacheKey(main.ParseOptions(), main.ScriptKind)) + utilsEntry, _ := session.parseCache.entries.Load(newParseCacheKey(utils.ParseOptions(), utils.ScriptKind)) + assert.Equal(t, mainEntry.refCount, 1) + assert.Equal(t, utilsEntry.refCount, 1) + + session.DidChangeFile(context.Background(), "file:///user/username/projects/myproject/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 x = 2;", + }, + }, + }) + ls, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/myproject/src/main.ts") + assert.NilError(t, err) + assert.Assert(t, ls.GetProgram().GetSourceFile("/user/username/projects/myproject/src/main.ts") != main) + assert.Equal(t, ls.GetProgram().GetSourceFile("/user/username/projects/myproject/src/utils.ts"), utils) + assert.Equal(t, mainEntry.refCount, 2) + assert.Equal(t, utilsEntry.refCount, 2) + release() + assert.Equal(t, mainEntry.refCount, 1) + assert.Equal(t, utilsEntry.refCount, 1) + }) + + t.Run("release file on close", func(t *testing.T) { + t.Parallel() + + session := setup(files) + session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/utils.ts", 1, files["/user/username/projects/myproject/src/utils.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + program := snapshot.ProjectCollection.InferredProject().Program + main := program.GetSourceFile("/user/username/projects/myproject/src/main.ts") + utils := program.GetSourceFile("/user/username/projects/myproject/src/utils.ts") + release() + mainEntry, _ := session.parseCache.entries.Load(newParseCacheKey(main.ParseOptions(), main.ScriptKind)) + utilsEntry, _ := session.parseCache.entries.Load(newParseCacheKey(utils.ParseOptions(), utils.ScriptKind)) + assert.Equal(t, mainEntry.refCount, 1) + assert.Equal(t, utilsEntry.refCount, 1) + + session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts") + _, err := session.GetLanguageService(context.Background(), "file:///user/username/projects/myproject/src/utils.ts") + assert.NilError(t, err) + assert.Equal(t, utilsEntry.refCount, 1) + assert.Equal(t, mainEntry.refCount, 0) + mainEntry, ok := session.parseCache.entries.Load(newParseCacheKey(main.ParseOptions(), main.ScriptKind)) + assert.Equal(t, ok, false) + }) + }) + + t.Run("extendedConfigCache", func(t *testing.T) { + files := map[string]any{ + "/user/username/projects/myproject/tsconfig.json": `{ + "extends": "./tsconfig.base.json" + }`, + "/user/username/projects/myproject/tsconfig.base.json": `{ + "compilerOptions": {} + }`, + "/user/username/projects/myproject/src/main.ts": "const x = 1;", + } + + t.Run("release extended configs with project close", func(t *testing.T) { + t.Parallel() + + session := setup(files) + session.DidOpenFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts", 1, files["/user/username/projects/myproject/src/main.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release := session.Snapshot() + config := snapshot.ConfigFileRegistry.GetConfig("/user/username/projects/myproject/tsconfig.json") + assert.Equal(t, config.ExtendedSourceFiles()[0], "/user/username/projects/myproject/tsconfig.base.json") + extendedConfigEntry, _ := session.extendedConfigCache.entries.Load("/user/username/projects/myproject/tsconfig.base.json") + assert.Equal(t, extendedConfigEntry.refCount, 1) + release() + + session.DidCloseFile(context.Background(), "file:///user/username/projects/myproject/src/main.ts") + session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript) + assert.Equal(t, extendedConfigEntry.refCount, 0) + }) + }) +} diff --git a/internal/project/scriptinfo.go b/internal/project/scriptinfo.go deleted file mode 100644 index fb3ad5fc69..0000000000 --- a/internal/project/scriptinfo.go +++ /dev/null @@ -1,238 +0,0 @@ -package project - -import ( - "slices" - "sync" - - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/ls" - "github.com/microsoft/typescript-go/internal/tspath" - "github.com/microsoft/typescript-go/internal/vfs" -) - -var _ ls.Script = (*ScriptInfo)(nil) - -type ScriptInfo struct { - fileName string - path tspath.Path - realpath tspath.Path - isDynamic bool - scriptKind core.ScriptKind - text string - version int - lineMap *ls.LineMap - - pendingReloadFromDisk bool - matchesDiskText bool - deferredDelete bool - - containingProjectsMu sync.RWMutex - containingProjects []*Project - - fs vfs.FS -} - -func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind, fs vfs.FS) *ScriptInfo { - isDynamic := isDynamicFileName(fileName) - realpath := core.IfElse(isDynamic, path, "") - return &ScriptInfo{ - fileName: fileName, - path: path, - realpath: realpath, - isDynamic: isDynamic, - scriptKind: scriptKind, - fs: fs, - } -} - -func (s *ScriptInfo) FileName() string { - return s.fileName -} - -func (s *ScriptInfo) Path() tspath.Path { - return s.path -} - -func (s *ScriptInfo) LineMap() *ls.LineMap { - if s.lineMap == nil { - s.lineMap = ls.ComputeLineStarts(s.Text()) - } - return s.lineMap -} - -func (s *ScriptInfo) Text() string { - s.reloadIfNeeded() - return s.text -} - -func (s *ScriptInfo) Version() int { - s.reloadIfNeeded() - return s.version -} - -func (s *ScriptInfo) ContainingProjects() []*Project { - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - return slices.Clone(s.containingProjects) -} - -func (s *ScriptInfo) reloadIfNeeded() { - if s.pendingReloadFromDisk { - if newText, ok := s.fs.ReadFile(s.fileName); ok { - s.SetTextFromDisk(newText) - } - } -} - -func (s *ScriptInfo) open(newText string) { - s.pendingReloadFromDisk = false - if newText != s.text { - s.setText(newText) - s.matchesDiskText = false - s.markContainingProjectsAsDirty() - } -} - -func (s *ScriptInfo) SetTextFromDisk(newText string) { - if newText != s.text { - s.setText(newText) - s.matchesDiskText = true - } -} - -func (s *ScriptInfo) close(fileExists bool) { - if fileExists && !s.pendingReloadFromDisk && !s.matchesDiskText { - s.pendingReloadFromDisk = true - s.markContainingProjectsAsDirty() - } - - s.containingProjectsMu.Lock() - defer s.containingProjectsMu.Unlock() - for _, project := range slices.Clone(s.containingProjects) { - if project.kind == KindInferred && project.isRoot(s) { - project.RemoveFile(s, fileExists) - s.detachFromProjectLocked(project) - } - } -} - -func (s *ScriptInfo) setText(newText string) { - s.text = newText - s.version++ - s.lineMap = nil -} - -func (s *ScriptInfo) markContainingProjectsAsDirty() { - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - for _, project := range s.containingProjects { - project.MarkFileAsDirty(s.path) - } -} - -// attachToProject attaches the script info to the project if it's not already attached -// and returns true if the script info was newly attached. -func (s *ScriptInfo) attachToProject(project *Project) bool { - if s.isAttached(project) { - return false - } - s.containingProjectsMu.Lock() - if s.isAttachedLocked(project) { - s.containingProjectsMu.Unlock() - return false - } - s.containingProjects = append(s.containingProjects, project) - s.containingProjectsMu.Unlock() - if project.compilerOptions.PreserveSymlinks != core.TSTrue { - s.ensureRealpath(project) - } - project.onFileAddedOrRemoved() - return true -} - -func (s *ScriptInfo) isAttached(project *Project) bool { - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - return s.isAttachedLocked(project) -} - -func (s *ScriptInfo) isAttachedLocked(project *Project) bool { - return slices.Contains(s.containingProjects, project) -} - -func (s *ScriptInfo) isOrphan() bool { - if s.deferredDelete { - return true - } - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - for _, project := range s.containingProjects { - if !project.isOrphan() { - return false - } - } - return true -} - -func (s *ScriptInfo) editContent(change core.TextChange) { - s.setText(change.ApplyTo(s.Text())) - s.markContainingProjectsAsDirty() -} - -func (s *ScriptInfo) ensureRealpath(project *Project) { - if s.realpath == "" { - realpath := project.FS().Realpath(string(s.path)) - s.realpath = project.toPath(realpath) - if s.realpath != s.path { - project.host.DocumentStore().AddRealpathMapping(s) - } - } -} - -func (s *ScriptInfo) getRealpathIfDifferent() (tspath.Path, bool) { - if s.realpath != "" && s.realpath != s.path { - return s.realpath, true - } - return "", false -} - -func (s *ScriptInfo) detachAllProjects() { - s.containingProjectsMu.Lock() - defer s.containingProjectsMu.Unlock() - for _, project := range s.containingProjects { - // !!! - // if (isConfiguredProject(p)) { - // p.getCachedDirectoryStructureHost().addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); - // } - project.RemoveFile(s, false /*fileExists*/) - } - s.containingProjects = nil -} - -func (s *ScriptInfo) detachFromProject(project *Project) { - s.containingProjectsMu.Lock() - defer s.containingProjectsMu.Unlock() - s.detachFromProjectLocked(project) -} - -func (s *ScriptInfo) detachFromProjectLocked(project *Project) { - if index := slices.Index(s.containingProjects, project); index != -1 { - s.containingProjects = slices.Delete(s.containingProjects, index, index+1) - } -} - -func (s *ScriptInfo) delayReloadNonMixedContentFile() { - if s.isDynamic { - panic("cannot reload dynamic file") - } - s.pendingReloadFromDisk = true - s.markContainingProjectsAsDirty() -} - -func (s *ScriptInfo) containedByDeferredClosedProject() bool { - s.containingProjectsMu.RLock() - defer s.containingProjectsMu.RUnlock() - return slices.ContainsFunc(s.containingProjects, func(project *Project) bool { - return project.deferredClose - }) -} diff --git a/internal/project/service.go b/internal/project/service.go deleted file mode 100644 index 9fbf1c5ed4..0000000000 --- a/internal/project/service.go +++ /dev/null @@ -1,787 +0,0 @@ -package project - -import ( - "context" - "errors" - "fmt" - "maps" - "runtime" - "strings" - "sync" - - "github.com/microsoft/typescript-go/internal/collections" - "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/tspath" - "github.com/microsoft/typescript-go/internal/vfs" -) - -type projectLoadKind int - -const ( - // Project is not created or updated, only looked up in cache - projectLoadKindFind projectLoadKind = iota - // Project is created and then its graph is updated - projectLoadKindCreate -) - -type ServiceOptions struct { - TypingsInstallerOptions - Logger *Logger - PositionEncoding lsproto.PositionEncodingKind - WatchEnabled bool - - ParsedFileCache ParsedFileCache -} - -var _ ProjectHost = (*Service)(nil) - -type Service struct { - host ServiceHost - options ServiceOptions - comparePathsOptions tspath.ComparePathsOptions - converters *ls.Converters - - projectsMu sync.RWMutex - configuredProjects map[tspath.Path]*Project - // inferredProjects is the list of all inferred projects, including the unrootedInferredProject - // if it exists - inferredProjects map[tspath.Path]*Project - - documentStore *DocumentStore - openFiles map[tspath.Path]string // values are projectRootPath, if provided - configFileForOpenFiles map[tspath.Path]string // default config project for open files !!! todo solution and project reference handling - defaultProjectFinder *defaultProjectFinder - configFileRegistry *ConfigFileRegistry - - typingsInstaller *TypingsInstaller - - compilerOptionsForInferredProjects *core.CompilerOptions -} - -func NewService(host ServiceHost, options ServiceOptions) *Service { - options.Logger.Info(fmt.Sprintf("currentDirectory:: %s useCaseSensitiveFileNames:: %t", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames())) - options.Logger.Info("libs Location:: " + host.DefaultLibraryPath()) - options.Logger.Info("globalTypingsCacheLocation:: " + host.TypingsLocation()) - service := &Service{ - host: host, - options: options, - comparePathsOptions: tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), - CurrentDirectory: host.GetCurrentDirectory(), - }, - - configuredProjects: make(map[tspath.Path]*Project), - inferredProjects: make(map[tspath.Path]*Project), - - documentStore: NewDocumentStore(DocumentStoreOptions{ - ComparePathsOptions: tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), - CurrentDirectory: host.GetCurrentDirectory(), - }, - ParsedFileCache: options.ParsedFileCache, - }), - openFiles: make(map[tspath.Path]string), - configFileForOpenFiles: make(map[tspath.Path]string), - } - service.defaultProjectFinder = &defaultProjectFinder{ - service: service, - configFileForOpenFiles: make(map[tspath.Path]string), - configFilesAncestorForOpenFiles: make(map[tspath.Path]map[string]string), - } - service.configFileRegistry = &ConfigFileRegistry{ - Host: service, - defaultProjectFinder: service.defaultProjectFinder, - } - service.converters = ls.NewConverters(options.PositionEncoding, func(fileName string) *ls.LineMap { - return service.documentStore.GetScriptInfoByPath(service.toPath(fileName)).LineMap() - }) - - return service -} - -// GetCurrentDirectory implements ProjectHost. -func (s *Service) GetCurrentDirectory() string { - return s.host.GetCurrentDirectory() -} - -// Log implements ProjectHost. -func (s *Service) Log(msg string) { - s.options.Logger.Info(msg) -} - -func (s *Service) Trace(msg string) { - s.Log(msg) -} - -func (s *Service) HasLevel(level LogLevel) bool { - return s.options.Logger.HasLevel(level) -} - -// DefaultLibraryPath implements ProjectHost. -func (s *Service) DefaultLibraryPath() string { - return s.host.DefaultLibraryPath() -} - -func (s *Service) Converters() *ls.Converters { - return s.converters -} - -// TypingsInstaller implements ProjectHost. -func (s *Service) TypingsInstaller() *TypingsInstaller { - if s.typingsInstaller != nil { - return s.typingsInstaller - } - - if typingsLocation := s.host.TypingsLocation(); typingsLocation != "" { - s.typingsInstaller = &TypingsInstaller{ - TypingsLocation: typingsLocation, - options: &s.options.TypingsInstallerOptions, - } - } - return s.typingsInstaller -} - -// DocumentStore implements ProjectHost. -func (s *Service) DocumentStore() *DocumentStore { - return s.documentStore -} - -// ConfigFileRegistry implements ProjectHost. -func (s *Service) ConfigFileRegistry() *ConfigFileRegistry { - return s.configFileRegistry -} - -// FS implements ProjectHost. -func (s *Service) FS() vfs.FS { - return s.host.FS() -} - -// PositionEncoding implements ProjectHost. -func (s *Service) PositionEncoding() lsproto.PositionEncodingKind { - return s.options.PositionEncoding -} - -// Client implements ProjectHost. -func (s *Service) Client() Client { - return s.host.Client() -} - -// IsWatchEnabled implements ProjectHost. -func (s *Service) IsWatchEnabled() bool { - return s.options.WatchEnabled -} - -func (s *Service) Projects() []*Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) - for _, project := range s.configuredProjects { - projects = append(projects, project) - } - for _, project := range s.inferredProjects { - projects = append(projects, project) - } - return projects -} - -func (s *Service) ConfiguredProject(path tspath.Path) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if project, ok := s.configuredProjects[path]; ok { - return project - } - return nil -} - -func (s *Service) InferredProject(rootPath tspath.Path) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if project, ok := s.inferredProjects[rootPath]; ok { - return project - } - return nil -} - -func (s *Service) isOpenFile(info *ScriptInfo) bool { - _, ok := s.openFiles[info.path] - return ok -} - -func (s *Service) OpenFile(fileName string, fileContent string, scriptKind core.ScriptKind, projectRootPath string) { - path := s.toPath(fileName) - existing := s.documentStore.GetScriptInfoByPath(path) - info := s.documentStore.getOrCreateScriptInfoWorker(fileName, path, scriptKind, true /*openedByClient*/, fileContent, true /*deferredDeleteOk*/, s.FS()) - s.openFiles[info.path] = projectRootPath - if existing == nil && info != nil && !info.isDynamic { - // Invoke wild card directory watcher to ensure that the file presence is reflected - s.configFileRegistry.tryInvokeWildCardDirectories(fileName, info.path) - } - result := s.assignProjectToOpenedScriptInfo(info) - s.cleanupProjectsAndScriptInfos(info, result) - s.printMemoryUsage() - s.printProjects() -} - -func (s *Service) ChangeFile(document lsproto.VersionedTextDocumentIdentifier, changes []lsproto.TextDocumentContentChangePartialOrWholeDocument) error { - fileName := ls.DocumentURIToFileName(document.Uri) - path := s.toPath(fileName) - scriptInfo := s.documentStore.GetScriptInfoByPath(path) - if scriptInfo == nil { - return fmt.Errorf("file %s not found", fileName) - } - - textChanges := make([]core.TextChange, len(changes)) - for i, change := range changes { - if partialChange := change.Partial; partialChange != nil { - textChanges[i] = s.converters.FromLSPTextChange(scriptInfo, partialChange) - } else if wholeChange := change.WholeDocument; wholeChange != nil { - textChanges[i] = core.TextChange{ - TextRange: core.NewTextRange(0, len(scriptInfo.Text())), - NewText: wholeChange.Text, - } - } else { - return errors.New("invalid change type") - } - } - - s.applyChangesToFile(scriptInfo, textChanges) - return nil -} - -func (s *Service) CloseFile(fileName string) { - if info := s.documentStore.GetScriptInfoByPath(s.toPath(fileName)); info != nil { - fileExists := !info.isDynamic && s.FS().FileExists(info.fileName) - info.close(fileExists) - delete(s.openFiles, info.path) - delete(s.defaultProjectFinder.configFileForOpenFiles, info.path) - delete(s.defaultProjectFinder.configFilesAncestorForOpenFiles, info.path) - s.configFileRegistry.releaseConfigsForInfo(info) - if !fileExists { - s.handleDeletedFile(info, false /*deferredDelete*/) - } - } -} - -func (s *Service) MarkFileSaved(fileName string, text string) { - if info := s.documentStore.GetScriptInfoByPath(s.toPath(fileName)); info != nil { - info.SetTextFromDisk(text) - } -} - -func (s *Service) EnsureDefaultProjectForURI(url lsproto.DocumentUri) *Project { - _, project := s.EnsureDefaultProjectForFile(ls.DocumentURIToFileName(url)) - return project -} - -func (s *Service) EnsureDefaultProjectForFile(fileName string) (*ScriptInfo, *Project) { - path := s.toPath(fileName) - if info := s.documentStore.GetScriptInfoByPath(path); info != nil && !info.isOrphan() { - if project := s.getDefaultProjectForScript(info); project != nil { - return info, project - } - } - s.ensureProjectStructureUpToDate() - if info := s.documentStore.GetScriptInfoByPath(path); info != nil { - if project := s.getDefaultProjectForScript(info); project != nil { - return info, project - } - } - panic("project not found") -} - -func (s *Service) Close() { - s.options.Logger.Close() -} - -func (s *Service) OnWatchedFilesChanged(ctx context.Context, changes []*lsproto.FileEvent) error { - seen := collections.NewSetWithSizeHint[lsproto.FileEvent](len(changes)) - - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - for _, change := range changes { - if !seen.AddIfAbsent(*change) { - continue - } - - fileName := ls.DocumentURIToFileName(change.Uri) - path := s.toPath(fileName) - if err, ok := s.configFileRegistry.onWatchedFilesChanged(path, change.Type); ok { - if err != nil { - return fmt.Errorf("error handling config file change: %w", err) - } - } else if _, ok := s.openFiles[path]; ok { - // open file - continue - } else if info := s.documentStore.GetScriptInfoByPath(path); info != nil { - // closed existing file - if change.Type == lsproto.FileChangeTypeDeleted { - s.handleDeletedFile(info, true /*deferredDelete*/) - } else { - info.deferredDelete = false - info.delayReloadNonMixedContentFile() - // !!! s.delayUpdateProjectGraphs(info.containingProjects, false /*clearSourceMapperCache*/) - // !!! s.handleSourceMapProjects(info) - } - } else { - for _, project := range s.configuredProjects { - project.onWatchEventForNilScriptInfo(fileName) - } - for _, project := range s.inferredProjects { - project.onWatchEventForNilScriptInfo(fileName) - } - s.configFileRegistry.tryInvokeWildCardDirectories(fileName, path) - } - } - - client := s.host.Client() - if client != nil { - return client.RefreshDiagnostics(ctx) - } - - return nil -} - -func (s *Service) ensureProjectStructureUpToDate() { - var hasChanges bool - s.projectsMu.RLock() - for _, project := range s.configuredProjects { - _, updated := project.updateGraph() - hasChanges = updated || hasChanges - } - for _, project := range s.inferredProjects { - _, updated := project.updateGraph() - hasChanges = updated || hasChanges - } - s.projectsMu.RUnlock() - if hasChanges { - s.ensureProjectForOpenFiles() - } -} - -func (s *Service) ensureProjectForOpenFiles() { - s.Log("Before ensureProjectForOpenFiles:") - s.printProjects() - - for filePath, projectRootPath := range s.openFiles { - info := s.documentStore.GetScriptInfoByPath(filePath) - if info == nil { - panic("scriptInfo not found for open file") - } - if info.isOrphan() { - s.assignOrphanScriptInfoToInferredProject(info, projectRootPath) - } else { - // !!! s.removeRootOfInferredProjectIfNowPartOfOtherProject(info) - } - } - s.projectsMu.RLock() - for _, project := range s.inferredProjects { - project.updateGraph() - } - s.projectsMu.RUnlock() - - s.Log("After ensureProjectForOpenFiles:") - s.printProjects() -} - -func (s *Service) applyChangesToFile(info *ScriptInfo, changes []core.TextChange) { - for _, change := range changes { - info.editContent(change) - } -} - -func (s *Service) handleDeletedFile(info *ScriptInfo, deferredDelete bool) { - if s.isOpenFile(info) { - panic("cannot delete an open file") - } - - // !!! - // s.handleSourceMapProjects(info) - containingProjects := info.ContainingProjects() - info.detachAllProjects() - if deferredDelete { - info.delayReloadNonMixedContentFile() - info.deferredDelete = true - } else { - s.deleteScriptInfo(info) - } - s.updateProjectGraphs(containingProjects, false /*clearSourceMapperCache*/) -} - -func (s *Service) deleteScriptInfo(info *ScriptInfo) { - if s.isOpenFile(info) { - panic("cannot delete an open file") - } - s.deleteScriptInfoLocked(info) -} - -func (s *Service) deleteScriptInfoLocked(info *ScriptInfo) { - s.documentStore.DeleteScriptInfo(info) - // !!! closeSourceMapFileWatcher -} - -func (s *Service) updateProjectGraphs(projects []*Project, clearSourceMapperCache bool) { - for _, project := range projects { - if clearSourceMapperCache { - project.clearSourceMapperCache() - } - project.markAsDirty() - } -} - -func (s *Service) createConfiguredProject(configFileName string, configFilePath tspath.Path) *Project { - s.projectsMu.Lock() - defer s.projectsMu.Unlock() - - // !!! config file existence cache stuff omitted - project := NewConfiguredProject(configFileName, configFilePath, s) - s.configuredProjects[configFilePath] = project - // !!! - // s.createConfigFileWatcherForParsedConfig(configFileName, configFilePath, project) - return project -} - -func (s *Service) assignProjectToOpenedScriptInfo(info *ScriptInfo) *openScriptInfoProjectResult { - // !!! todo retain projects list when its multiple projects that are looked up - result := s.defaultProjectFinder.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo(info, projectLoadKindCreate) - - for _, project := range info.ContainingProjects() { - project.updateGraph() - } - if info.isOrphan() { - // !!! - // more new "optimized" stuff - if projectRootDirectory, ok := s.openFiles[info.path]; ok { - s.assignOrphanScriptInfoToInferredProject(info, projectRootDirectory) - } else { - panic("opened script info should be in openFiles map") - } - } - return result -} - -func (s *Service) cleanupProjectsAndScriptInfos(openInfo *ScriptInfo, retainedByOpenFile *openScriptInfoProjectResult) { - // This was postponed from closeOpenFile to after opening next file, - // so that we can reuse the project if we need to right away - // Remove all the non marked projects - s.cleanupConfiguredProjects(openInfo, retainedByOpenFile) - - // Remove orphan inferred projects now that we have reused projects - // We need to create a duplicate because we cant guarantee order after removal - s.projectsMu.RLock() - inferredProjects := maps.Clone(s.inferredProjects) - s.projectsMu.RUnlock() - for _, inferredProject := range inferredProjects { - if inferredProject.isOrphan() { - s.removeProject(inferredProject) - } - } - - // Delete the orphan files here because there might be orphan script infos (which are not part of project) - // when some file/s were closed which resulted in project removal. - // It was then postponed to cleanup these script infos so that they can be reused if - // the file from that old project is reopened because of opening file from here. - s.removeOrphanScriptInfos() -} - -func (s *Service) cleanupConfiguredProjects(openInfo *ScriptInfo, retainedByOpenFile *openScriptInfoProjectResult) { - s.projectsMu.RLock() - toRemoveProjects := maps.Clone(s.configuredProjects) - s.projectsMu.RUnlock() - - toRemoveConfigs := s.configFileRegistry.ConfigFiles.ToMap() - - // !!! handle declarationMap - retainConfiguredProject := func(r *openScriptInfoProjectResult) { - if r == nil { - return - } - r.seenProjects.Range(func(project *Project, _ projectLoadKind) bool { - delete(toRemoveProjects, project.configFilePath) - return true - }) - r.seenConfigs.Range(func(config tspath.Path, _ projectLoadKind) bool { - delete(toRemoveConfigs, config) - return true - }) - // // Keep original projects used - // markOriginalProjectsAsUsed(project); - // // Keep all the references alive - // forEachReferencedProject(project, retainConfiguredProject); - } - - if retainedByOpenFile != nil { - retainConfiguredProject(retainedByOpenFile) - } - - // Everything needs to be retained, fast path to skip all the work - if len(toRemoveProjects) != 0 { - // Retain default configured project for open script info - for path := range s.openFiles { - if path == openInfo.path { - continue - } - info := s.documentStore.GetScriptInfoByPath(path) - // We want to retain the projects for open file if they are pending updates so deferredClosed projects are ok - result := s.defaultProjectFinder.tryFindDefaultConfiguredProjectAndLoadAncestorsForOpenScriptInfo( - info, - projectLoadKindFind, - ) - retainConfiguredProject(result) - // Everything needs to be retained, fast path to skip all the work - if len(toRemoveProjects) == 0 { - break - } - } - } - for _, project := range toRemoveProjects { - s.removeProject(project) - } - s.configFileRegistry.cleanup(toRemoveConfigs) -} - -func (s *Service) removeProject(project *Project) { - s.Log("remove Project:: " + project.name) - s.Log(project.print( /*writeProjectFileNames*/ true /*writeFileExplaination*/, true /*writeFileVersionAndText*/, false, &strings.Builder{})) - s.projectsMu.Lock() - switch project.kind { - case KindConfigured: - delete(s.configuredProjects, project.configFilePath) - case KindInferred: - delete(s.inferredProjects, project.rootPath) - } - s.projectsMu.Unlock() - project.Close() -} - -func (s *Service) removeOrphanScriptInfos() { - // Get all script infos from document store - scriptInfos := make(map[tspath.Path]*ScriptInfo) - s.documentStore.ForEachScriptInfo(func(info *ScriptInfo) { - scriptInfos[info.path] = info - }) - - toRemoveScriptInfos := maps.Clone(scriptInfos) - - for _, info := range scriptInfos { - if info.deferredDelete { - continue - } - - // If script info is not open and orphan, remove it - if !s.isOpenFile(info) && - info.isOrphan() && - // !scriptInfoIsContainedByBackgroundProject(info) && - !info.containedByDeferredClosedProject() { - // !!! dts map related infos and code - continue - } - // Retain this script info - delete(toRemoveScriptInfos, info.path) - } - - // if there are not projects that include this script info - delete it - for _, info := range toRemoveScriptInfos { - s.deleteScriptInfoLocked(info) - } -} - -func (s *Service) assignOrphanScriptInfoToInferredProject(info *ScriptInfo, projectRootDirectory string) *Project { - if !info.isOrphan() { - panic("scriptInfo is not orphan") - } - - project := s.getOrCreateInferredProjectForProjectRootPath(info, projectRootDirectory) - project.AddInferredProjectRoot(info) - project.updateGraph() - return project - // !!! old code ensures that scriptInfo is only part of one project -} - -func (s *Service) getOrCreateInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { - project := s.getInferredProjectForProjectRootPath(info, projectRootDirectory) - if project != nil { - return project - } - if projectRootDirectory != "" { - return s.createInferredProject(projectRootDirectory, s.toPath(projectRootDirectory)) - } - return s.createInferredProject(s.GetCurrentDirectory(), "") -} - -func (s *Service) getInferredProjectForProjectRootPath(info *ScriptInfo, projectRootDirectory string) *Project { - s.projectsMu.RLock() - defer s.projectsMu.RUnlock() - if projectRootDirectory != "" { - projectRootPath := s.toPath(projectRootDirectory) - if project, ok := s.inferredProjects[projectRootPath]; ok { - return project - } - return nil - } - - if !info.isDynamic { - var bestMatch *Project - for _, project := range s.inferredProjects { - if project.rootPath != "" && - tspath.ContainsPath(string(project.rootPath), string(info.path), s.comparePathsOptions) && - (bestMatch == nil || len(bestMatch.rootPath) <= len(project.rootPath)) { - bestMatch = project - } - } - - if bestMatch != nil { - return bestMatch - } - } - - // unrooted inferred project if no best match found - if unrootedProject, ok := s.inferredProjects[""]; ok { - return unrootedProject - } - return nil -} - -func (s *Service) getDefaultProjectForScript(scriptInfo *ScriptInfo) *Project { - containingProjects := scriptInfo.ContainingProjects() - switch len(containingProjects) { - case 0: - return nil - case 1: - project := containingProjects[0] - if project.deferredClose || project.kind == KindAutoImportProvider || project.kind == KindAuxiliary { - return nil - } - return project - default: - // If this file belongs to multiple projects, below is the order in which default project is used - // - first external project - // - for open script info, its default configured project during opening is default if info is part of it - // - first configured project of which script info is not a source of project reference redirect - // - first configured project - // - first inferred project - var firstConfiguredProject *Project - var firstInferredProject *Project - var firstNonSourceOfProjectReferenceRedirect *Project - var defaultConfiguredProject *Project - - for index, project := range containingProjects { - if project.kind == KindConfigured { - if project.deferredClose { - continue - } - if !project.isSourceFromProjectReference(scriptInfo) { - if defaultConfiguredProject == nil && index != len(containingProjects)-1 { - defaultConfiguredProject = s.defaultProjectFinder.findDefaultConfiguredProject(scriptInfo) - } - if defaultConfiguredProject == project { - return project - } - if firstNonSourceOfProjectReferenceRedirect == nil { - firstNonSourceOfProjectReferenceRedirect = project - } - } - if firstConfiguredProject == nil { - firstConfiguredProject = project - } - } else if firstInferredProject == nil && project.kind == KindInferred { - firstInferredProject = project - } - } - if defaultConfiguredProject != nil { - return defaultConfiguredProject - } - if firstNonSourceOfProjectReferenceRedirect != nil { - return firstNonSourceOfProjectReferenceRedirect - } - if firstConfiguredProject != nil { - return firstConfiguredProject - } - if firstInferredProject != nil { - return firstInferredProject - } - } - return nil -} - -func (s *Service) createInferredProject(currentDirectory string, projectRootPath tspath.Path) *Project { - s.projectsMu.Lock() - defer s.projectsMu.Unlock() - if existingProject, ok := s.inferredProjects[projectRootPath]; ok { - return existingProject - } - - compilerOptions := s.compilerOptionsForInferredProjects - if compilerOptions == nil { - compilerOptions = &core.CompilerOptions{ - AllowJs: core.TSTrue, - Module: core.ModuleKindESNext, - ModuleResolution: core.ModuleResolutionKindBundler, - Target: core.ScriptTargetES2022, - Jsx: core.JsxEmitReactJSX, - AllowImportingTsExtensions: core.TSTrue, - StrictNullChecks: core.TSTrue, - StrictFunctionTypes: core.TSTrue, - SourceMap: core.TSTrue, - ESModuleInterop: core.TSTrue, - AllowNonTsExtensions: core.TSTrue, - ResolveJsonModule: core.TSTrue, - } - } - project := NewInferredProject(compilerOptions, currentDirectory, projectRootPath, s) - s.inferredProjects[project.rootPath] = project - return project -} - -func (s *Service) toPath(fileName string) tspath.Path { - return tspath.ToPath(fileName, s.GetCurrentDirectory(), s.FS().UseCaseSensitiveFileNames()) -} - -func (s *Service) printProjects() { - if !s.options.Logger.HasLevel(LogLevelNormal) { - return - } - - var builder strings.Builder - s.projectsMu.RLock() - for _, project := range s.configuredProjects { - project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) - builder.WriteRune('\n') - } - for _, project := range s.inferredProjects { - project.print(false /*writeFileNames*/, false /*writeFileExpanation*/, false /*writeFileVersionAndText*/, &builder) - builder.WriteRune('\n') - } - s.projectsMu.RUnlock() - - builder.WriteString("Open files:") - for path, projectRootPath := range s.openFiles { - info := s.documentStore.GetScriptInfoByPath(path) - builder.WriteString(fmt.Sprintf("\n\tFileName: %s ProjectRootPath: %s", info.fileName, projectRootPath)) - builder.WriteString("\n\t\tProjects: " + strings.Join(core.Map(info.ContainingProjects(), func(project *Project) string { return project.name }), ", ")) - } - builder.WriteString("\n" + hr) - s.Log(builder.String()) -} - -func (s *Service) logf(format string, args ...any) { - s.Log(fmt.Sprintf(format, args...)) -} - -func (s *Service) printMemoryUsage() { - runtime.GC() // Force garbage collection to get accurate memory stats - var memStats runtime.MemStats - runtime.ReadMemStats(&memStats) - s.logf("MemoryStats:\n\tAlloc: %v KB\n\tSys: %v KB\n\tNumGC: %v", memStats.Alloc/1024, memStats.Sys/1024, memStats.NumGC) -} - -// !!! per root compiler options -func (s *Service) SetCompilerOptionsForInferredProjects(compilerOptions *core.CompilerOptions) { - s.compilerOptionsForInferredProjects = compilerOptions - - // !!! set compiler options for all inferred projects - // for _, project := range s.inferredProjects { - // project.SetCompilerOptions(compilerOptions) - // } -} diff --git a/internal/project/service_test.go b/internal/project/service_test.go deleted file mode 100644 index 0d756d584b..0000000000 --- a/internal/project/service_test.go +++ /dev/null @@ -1,674 +0,0 @@ -package project_test - -import ( - "maps" - "slices" - "testing" - - "github.com/microsoft/typescript-go/internal/bundled" - "github.com/microsoft/typescript-go/internal/core" - "github.com/microsoft/typescript-go/internal/lsp/lsproto" - "github.com/microsoft/typescript-go/internal/project" - "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" - "gotest.tools/v3/assert" -) - -func TestService(t *testing.T) { - t.Parallel() - if !bundled.Embedded { - t.Skip("bundled files are not embedded") - } - - defaultFiles := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true, - "module": "nodenext", - "strict": true - }, - "include": ["src"] - }`, - "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, - "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, - "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, - } - - t.Run("OpenFile", func(t *testing.T) { - t.Parallel() - t.Run("create configured project", func(t *testing.T) { - t.Parallel() - service, _ := projecttestutil.Setup(defaultFiles, nil) - assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/home/projects/TS/p1/src/index.ts", defaultFiles["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 1) - p := service.Projects()[0] - assert.Equal(t, p.Kind(), project.KindConfigured) - xScriptInfo := service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")) - assert.Assert(t, xScriptInfo != nil) - assert.Equal(t, xScriptInfo.Text(), "export const x = 1;") - }) - - t.Run("create inferred project", func(t *testing.T) { - t.Parallel() - service, _ := projecttestutil.Setup(defaultFiles, nil) - service.OpenFile("/home/projects/TS/p1/config.ts", defaultFiles["/home/projects/TS/p1/config.ts"], core.ScriptKindTS, "") - // Find tsconfig, load, notice config.ts is not included, create inferred project - assert.Equal(t, len(service.Projects()), 2) - _, proj := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/config.ts") - assert.Equal(t, proj.Kind(), project.KindInferred) - }) - - t.Run("inferred project for in-memory files", func(t *testing.T) { - t.Parallel() - service, _ := projecttestutil.Setup(defaultFiles, nil) - service.OpenFile("/home/projects/TS/p1/config.ts", defaultFiles["/home/projects/TS/p1/config.ts"], core.ScriptKindTS, "") - service.OpenFile("^/untitled/ts-nul-authority/Untitled-1", "x", core.ScriptKindTS, "") - service.OpenFile("^/untitled/ts-nul-authority/Untitled-2", "y", core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - _, p1 := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/config.ts") - _, p2 := service.EnsureDefaultProjectForFile("^/untitled/ts-nul-authority/Untitled-1") - _, p3 := service.EnsureDefaultProjectForFile("^/untitled/ts-nul-authority/Untitled-2") - assert.Equal(t, p1, p2) - assert.Equal(t, p1, p3) - }) - - t.Run("inferred project JS file", func(t *testing.T) { - t.Parallel() - jsFiles := map[string]string{ - "/home/projects/TS/p1/index.js": `import { x } from "./x";`, - } - service, _ := projecttestutil.Setup(jsFiles, nil) - service.OpenFile("/home/projects/TS/p1/index.js", jsFiles["/home/projects/TS/p1/index.js"], core.ScriptKindJS, "") - assert.Equal(t, len(service.Projects()), 1) - project := service.Projects()[0] - assert.Assert(t, project.GetProgram().GetSourceFile("/home/projects/TS/p1/index.js") != nil) - }) - }) - - t.Run("ChangeFile", func(t *testing.T) { - t.Parallel() - t.Run("update script info eagerly and program lazily", func(t *testing.T) { - t.Parallel() - service, _ := projecttestutil.Setup(defaultFiles, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", defaultFiles["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - info, proj := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/x.ts") - programBefore := proj.GetProgram() - err := service.ChangeFile( - lsproto.VersionedTextDocumentIdentifier{ - Uri: "file:///home/projects/TS/p1/src/x.ts", - Version: 1, - }, - []lsproto.TextDocumentContentChangePartialOrWholeDocument{ - { - Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ - Range: lsproto.Range{ - Start: lsproto.Position{ - Line: 0, - Character: 17, - }, - End: lsproto.Position{ - Line: 0, - Character: 18, - }, - }, - Text: "2", - }), - }, - }, - ) - assert.NilError(t, err) - assert.Equal(t, info.Text(), "export const x = 2;") - assert.Equal(t, proj.CurrentProgram(), programBefore) - assert.Equal(t, programBefore.GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "export const x = 1;") - assert.Equal(t, proj.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "export const x = 2;") - }) - - t.Run("unchanged source files are reused", func(t *testing.T) { - t.Parallel() - service, _ := projecttestutil.Setup(defaultFiles, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", defaultFiles["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - _, proj := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/x.ts") - programBefore := proj.GetProgram() - indexFileBefore := programBefore.GetSourceFile("/home/projects/TS/p1/src/index.ts") - err := service.ChangeFile( - lsproto.VersionedTextDocumentIdentifier{ - Uri: "file:///home/projects/TS/p1/src/x.ts", - Version: 1, - }, - []lsproto.TextDocumentContentChangePartialOrWholeDocument{ - { - Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ - Range: lsproto.Range{ - Start: lsproto.Position{ - Line: 0, - Character: 0, - }, - End: lsproto.Position{ - Line: 0, - Character: 0, - }, - }, - Text: ";", - }), - }, - }, - ) - assert.NilError(t, err) - assert.Equal(t, proj.GetProgram().GetSourceFile("/home/projects/TS/p1/src/index.ts"), indexFileBefore) - }) - - t.Run("change can pull in new files", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - files["/home/projects/TS/p1/y.ts"] = `export const y = 2;` - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - assert.Check(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/y.ts")) == nil) - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - err := service.ChangeFile( - lsproto.VersionedTextDocumentIdentifier{ - Uri: "file:///home/projects/TS/p1/src/index.ts", - Version: 1, - }, - []lsproto.TextDocumentContentChangePartialOrWholeDocument{ - { - Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ - Range: lsproto.Range{ - Start: lsproto.Position{ - Line: 0, - Character: 0, - }, - End: lsproto.Position{ - Line: 0, - Character: 0, - }, - }, - Text: `import { y } from "../y";\n`, - }), - }, - }, - ) - assert.NilError(t, err) - service.EnsureDefaultProjectForFile("/home/projects/TS/p1/y.ts") - }) - - t.Run("single-file change followed by config change reloads program", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - files["/home/projects/TS/p1/tsconfig.json"] = `{ - "compilerOptions": { - "noLib": true, - "module": "nodenext", - "strict": true, - }, - "include": ["src/index.ts"] - }` - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - programBefore := project.GetProgram() - assert.Equal(t, len(programBefore.GetSourceFiles()), 2) - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - err := service.ChangeFile( - lsproto.VersionedTextDocumentIdentifier{ - Uri: "file:///home/projects/TS/p1/src/index.ts", - Version: 1, - }, - []lsproto.TextDocumentContentChangePartialOrWholeDocument{ - { - Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ - Range: lsproto.Range{ - Start: lsproto.Position{ - Line: 0, - Character: 0, - }, - End: lsproto.Position{ - Line: 0, - Character: 0, - }, - }, - Text: "\n", - }), - }, - }, - ) - assert.NilError(t, err) - - err = host.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", `{ - "compilerOptions": { - "noLib": true, - "module": "nodenext", - "strict": true, - }, - "include": ["./**/*"] - }`, false) - assert.NilError(t, err) - - err = service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeChanged, - Uri: "file:///home/projects/TS/p1/tsconfig.json", - }, - }) - assert.NilError(t, err) - - programAfter := project.GetProgram() - assert.Equal(t, len(programAfter.GetSourceFiles()), 3) - }) - }) - - t.Run("CloseFile", func(t *testing.T) { - t.Parallel() - t.Run("Configured projects", func(t *testing.T) { - t.Parallel() - t.Run("delete a file, close it, recreate it", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - assert.Equal(t, service.DocumentStore().SourceFileCount(), 2) - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - assert.NilError(t, host.FS().Remove("/home/projects/TS/p1/src/x.ts")) - - service.CloseFile("/home/projects/TS/p1/src/x.ts") - assert.Check(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")) == nil) - assert.Check(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) - assert.Equal(t, service.DocumentStore().SourceFileCount(), 1) - - err := host.FS().WriteFile("/home/projects/TS/p1/src/x.ts", "", false) - assert.NilError(t, err) - - service.OpenFile("/home/projects/TS/p1/src/x.ts", "", core.ScriptKindTS, "") - assert.Equal(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")).Text(), "") - assert.Check(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil) - assert.Equal(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "") - }) - }) - - t.Run("Inferred projects", func(t *testing.T) { - t.Parallel() - t.Run("delete a file, close it, recreate it", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - delete(files, "/home/projects/TS/p1/tsconfig.json") - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - err := host.FS().Remove("/home/projects/TS/p1/src/x.ts") - assert.NilError(t, err) - - service.CloseFile("/home/projects/TS/p1/src/x.ts") - assert.Check(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")) == nil) - assert.Check(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) - - err = host.FS().WriteFile("/home/projects/TS/p1/src/x.ts", "", false) - assert.NilError(t, err) - - service.OpenFile("/home/projects/TS/p1/src/x.ts", "", core.ScriptKindTS, "") - assert.Equal(t, service.DocumentStore().GetScriptInfoByPath(serviceToPath(service, "/home/projects/TS/p1/src/x.ts")).Text(), "") - assert.Check(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil) - assert.Equal(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "") - }) - }) - }) - - t.Run("Source file sharing", func(t *testing.T) { - t.Parallel() - t.Run("projects with similar options share source files", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - files["/home/projects/TS/p2/tsconfig.json"] = `{ - "compilerOptions": { - "noLib": true, - "module": "nodenext", - "strict": true, - "noCheck": true // Added - }, - }` - files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"], core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - _, p1 := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - _, p2 := service.EnsureDefaultProjectForFile("/home/projects/TS/p2/src/index.ts") - assert.Equal( - t, - p1.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts"), - p2.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts"), - ) - }) - - t.Run("projects with different options do not share source files", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - files["/home/projects/TS/p2/tsconfig.json"] = `{ - "compilerOptions": { - "module": "nodenext", - "jsx": "react" - } - }` - files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` - service, _ := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", files["/home/projects/TS/p2/src/index.ts"], core.ScriptKindTS, "") - assert.Equal(t, len(service.Projects()), 2) - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - _, p1 := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - _, p2 := service.EnsureDefaultProjectForFile("/home/projects/TS/p2/src/index.ts") - x1 := p1.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") - x2 := p2.GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") - assert.Assert(t, x1 != nil && x2 != nil) - assert.Assert(t, x1 != x2) - }) - }) - - t.Run("Watch", func(t *testing.T) { - t.Parallel() - - t.Run("change open file", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - programBefore := project.GetProgram() - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - err := host.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 2;`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeChanged, - Uri: "file:///home/projects/TS/p1/src/x.ts", - }, - })) - - assert.Equal(t, programBefore, project.GetProgram()) - }) - - t.Run("change closed program file", func(t *testing.T) { - t.Parallel() - files := maps.Clone(defaultFiles) - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - programBefore := project.GetProgram() - // Avoid using initial file set after this point - files = nil //nolint:ineffassign - - err := host.FS().WriteFile("/home/projects/TS/p1/src/x.ts", `export const x = 2;`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeChanged, - Uri: "file:///home/projects/TS/p1/src/x.ts", - }, - })) - - assert.Check(t, project.GetProgram() != programBefore) - }) - - t.Run("change config file", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true, - "strict": false - } - }`, - "/home/projects/TS/p1/src/x.ts": `export declare const x: number | undefined;`, - "/home/projects/TS/p1/src/index.ts": ` - import { x } from "./x"; - let y: number = x;`, - } - - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - program := project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) - - err := host.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", `{ - "compilerOptions": { - "noLib": false, - "strict": true - } - }`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeChanged, - Uri: "file:///home/projects/TS/p1/tsconfig.json", - }, - })) - - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) - }) - - t.Run("delete explicitly included file", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true, - }, - "files": ["src/index.ts", "src/x.ts"] - }`, - "/home/projects/TS/p1/src/x.ts": `export declare const x: number | undefined;`, - "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, - } - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - program := project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) - - err := host.FS().Remove("/home/projects/TS/p1/src/x.ts") - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeDeleted, - Uri: "file:///home/projects/TS/p1/src/x.ts", - }, - })) - - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) - assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) - }) - - t.Run("delete wildcard included file", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true - }, - "include": ["src"] - }`, - "/home/projects/TS/p1/src/index.ts": `let x = 2;`, - "/home/projects/TS/p1/src/x.ts": `let y = x;`, - } - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/x.ts") - program := project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/x.ts"))), 0) - - err := host.FS().Remove("/home/projects/TS/p1/src/index.ts") - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeDeleted, - Uri: "file:///home/projects/TS/p1/src/index.ts", - }, - })) - - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/x.ts"))), 1) - }) - - t.Run("create explicitly included file", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true - }, - "files": ["src/index.ts", "src/y.ts"] - }`, - "/home/projects/TS/p1/src/index.ts": `import { y } from "./y";`, - } - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - program := project.GetProgram() - - // Initially should have an error because y.ts is missing - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) - - // Missing location should be watched - assert.DeepEqual(t, host.ClientMock.WatchFilesCalls()[0].Watchers, []*lsproto.FileSystemWatcher{ - { - Kind: ptrTo(lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete), - GlobPattern: lsproto.PatternOrRelativePattern{ - Pattern: ptrTo("/home/projects/TS/p1/src/index.ts"), - }, - }, - { - Kind: ptrTo(lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete), - GlobPattern: lsproto.PatternOrRelativePattern{ - Pattern: ptrTo("/home/projects/TS/p1/src/y.ts"), - }, - }, - { - Kind: ptrTo(lsproto.WatchKindCreate | lsproto.WatchKindChange | lsproto.WatchKindDelete), - GlobPattern: lsproto.PatternOrRelativePattern{ - Pattern: ptrTo("/home/projects/TS/p1/tsconfig.json"), - }, - }, - }) - - // Add the missing file - err := host.FS().WriteFile("/home/projects/TS/p1/src/y.ts", `export const y = 1;`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeCreated, - Uri: "file:///home/projects/TS/p1/src/y.ts", - }, - })) - - // Error should be resolved - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) - assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/y.ts") != nil) - }) - - t.Run("create failed lookup location", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true - }, - "files": ["src/index.ts"] - }`, - "/home/projects/TS/p1/src/index.ts": `import { z } from "./z";`, - } - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - program := project.GetProgram() - - // Initially should have an error because z.ts is missing - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) - - // Missing location should be watched - assert.Check(t, slices.ContainsFunc(host.ClientMock.WatchFilesCalls()[1].Watchers, func(w *lsproto.FileSystemWatcher) bool { - return *w.GlobPattern.Pattern == "/home/projects/TS/p1/src/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}" && *w.Kind == lsproto.WatchKindCreate - })) - - // Add a new file through failed lookup watch - err := host.FS().WriteFile("/home/projects/TS/p1/src/z.ts", `export const z = 1;`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeCreated, - Uri: "file:///home/projects/TS/p1/src/z.ts", - }, - })) - - // Error should be resolved and the new file should be included in the program - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) - assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/z.ts") != nil) - }) - - t.Run("create wildcard included file", func(t *testing.T) { - t.Parallel() - files := map[string]string{ - "/home/projects/TS/p1/tsconfig.json": `{ - "compilerOptions": { - "noLib": true - }, - "include": ["src"] - }`, - "/home/projects/TS/p1/src/index.ts": `a;`, - } - service, host := projecttestutil.Setup(files, nil) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") - program := project.GetProgram() - - // Initially should have an error because declaration for 'a' is missing - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) - - // Add a new file through wildcard watch - - err := host.FS().WriteFile("/home/projects/TS/p1/src/a.ts", `const a = 1;`, false) - assert.NilError(t, err) - - assert.NilError(t, service.OnWatchedFilesChanged(t.Context(), []*lsproto.FileEvent{ - { - Type: lsproto.FileChangeTypeCreated, - Uri: "file:///home/projects/TS/p1/src/a.ts", - }, - })) - - // Error should be resolved and the new file should be included in the program - program = project.GetProgram() - assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) - assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/a.ts") != nil) - }) - }) -} - -func ptrTo[T any](v T) *T { - return &v -} diff --git a/internal/project/session.go b/internal/project/session.go new file mode 100644 index 0000000000..d1d23aae49 --- /dev/null +++ b/internal/project/session.go @@ -0,0 +1,664 @@ +package project + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/microsoft/typescript-go/internal/ast" + "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/background" + "github.com/microsoft/typescript-go/internal/project/logging" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" +) + +// SessionOptions are the immutable initialization options for a session. +// Snapshots may reference them as a pointer since they never change. +type SessionOptions struct { + CurrentDirectory string + DefaultLibraryPath string + TypingsLocation string + PositionEncoding lsproto.PositionEncodingKind + WatchEnabled bool + LoggingEnabled bool + DebounceDelay time.Duration +} + +type SessionInit struct { + Options *SessionOptions + FS vfs.FS + Client Client + Logger logging.Logger + NpmExecutor ata.NpmExecutor + ParseCache *ParseCache +} + +// Session manages the state of an LSP session. It receives textDocument +// events and requests for LanguageService objects from the LPS server +// and processes them into immutable snapshots as the data source for +// LanguageServices. When Session transitions from one snapshot to the +// next, it diffs them and updates file watchers and Automatic Type +// Acquisition (ATA) state accordingly. +type Session struct { + options *SessionOptions + toPath func(string) tspath.Path + client Client + logger logging.Logger + npmExecutor ata.NpmExecutor + fs *overlayFS + + // parseCache is the ref-counted cache of source files used when + // creating programs during snapshot cloning. + parseCache *ParseCache + // extendedConfigCache is the ref-counted cache of tsconfig ASTs + // that are used in the "extends" of another tsconfig. + extendedConfigCache *extendedConfigCache + // programCounter counts how many snapshots reference a program. + // When a program is no longer referenced, its source files are + // released from the parseCache. + programCounter *programCounter + + compilerOptionsForInferredProjects *core.CompilerOptions + typingsInstaller *ata.TypingsInstaller + backgroundQueue *background.Queue + + // snapshotID is the counter for snapshot IDs. It does not necessarily + // equal the `snapshot.ID`. It is stored on Session instead of globally + // so IDs are predictable in tests. + snapshotID atomic.Uint64 + + // snapshot is the current immutable state of all projects. + snapshot *Snapshot + snapshotMu sync.RWMutex + + // pendingFileChanges are accumulated from textDocument/* events delivered + // by the LSP server through DidOpenFile(), DidChangeFile(), etc. They are + // applied to the next snapshot update. + pendingFileChanges []FileChange + pendingFileChangesMu sync.Mutex + + // pendingATAChanges are produced by Automatic Type Acquisition (ATA) + // installations and applied to the next snapshot update. + pendingATAChanges map[tspath.Path]*ATAStateChange + pendingATAChangesMu sync.Mutex + + // snapshotUpdateCancel is the cancelation function for a scheduled + // snapshot update. Snapshot updates are debounced after file watch + // changes since many watch events can occur in quick succession + // during `npm install` or git operations. + // !!! This can probably be replaced by ScheduleDiagnosticsRefresh() + snapshotUpdateCancel context.CancelFunc + snapshotUpdateMu sync.Mutex + + // diagnosticsRefreshCancel is the cancelation function for a scheduled + // diagnostics refresh. Diagnostics refreshes are scheduled and debounced + // after file watch changes and ATA updates. + diagnosticsRefreshCancel context.CancelFunc + diagnosticsRefreshMu sync.Mutex +} + +func NewSession(init *SessionInit) *Session { + currentDirectory := init.Options.CurrentDirectory + useCaseSensitiveFileNames := init.FS.UseCaseSensitiveFileNames() + toPath := func(fileName string) tspath.Path { + return tspath.ToPath(fileName, currentDirectory, useCaseSensitiveFileNames) + } + overlayFS := newOverlayFS(init.FS, make(map[tspath.Path]*overlay), init.Options.PositionEncoding, toPath) + parseCache := init.ParseCache + if parseCache == nil { + parseCache = &ParseCache{} + } + extendedConfigCache := &extendedConfigCache{} + + session := &Session{ + options: init.Options, + toPath: toPath, + client: init.Client, + logger: init.Logger, + npmExecutor: init.NpmExecutor, + fs: overlayFS, + parseCache: parseCache, + extendedConfigCache: extendedConfigCache, + programCounter: &programCounter{}, + backgroundQueue: background.NewQueue(), + snapshotID: atomic.Uint64{}, + snapshot: NewSnapshot( + uint64(0), + &snapshotFS{ + toPath: toPath, + fs: init.FS, + }, + init.Options, + parseCache, + extendedConfigCache, + &ConfigFileRegistry{}, + nil, + toPath, + ), + pendingATAChanges: make(map[tspath.Path]*ATAStateChange), + } + + if init.Options.TypingsLocation != "" && init.NpmExecutor != nil { + session.typingsInstaller = ata.NewTypingsInstaller(&ata.TypingsInstallerOptions{ + TypingsLocation: init.Options.TypingsLocation, + ThrottleLimit: 5, + }, session) + } + + return session +} + +// FS implements module.ResolutionHost +func (s *Session) FS() vfs.FS { + return s.fs.fs +} + +// GetCurrentDirectory implements module.ResolutionHost +func (s *Session) GetCurrentDirectory() string { + return s.options.CurrentDirectory +} + +// Trace implements module.ResolutionHost +func (s *Session) Trace(msg string) { + panic("ATA module resolution should not use tracing") +} + +func (s *Session) DidOpenFile(ctx context.Context, uri lsproto.DocumentUri, version int32, content string, languageKind lsproto.LanguageKind) { + s.cancelDiagnosticsRefresh() + s.pendingFileChangesMu.Lock() + s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ + Kind: FileChangeKindOpen, + URI: uri, + Version: version, + Content: content, + LanguageKind: languageKind, + }) + changes, overlays := s.flushChangesLocked(ctx) + s.pendingFileChangesMu.Unlock() + s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + fileChanges: changes, + requestedURIs: []lsproto.DocumentUri{uri}, + }) +} + +func (s *Session) DidCloseFile(ctx context.Context, uri lsproto.DocumentUri) { + s.cancelDiagnosticsRefresh() + s.pendingFileChangesMu.Lock() + defer s.pendingFileChangesMu.Unlock() + s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ + Kind: FileChangeKindClose, + URI: uri, + Hash: s.fs.getFile(uri.FileName()).Hash(), + }) +} + +func (s *Session) DidChangeFile(ctx context.Context, uri lsproto.DocumentUri, version int32, changes []lsproto.TextDocumentContentChangePartialOrWholeDocument) { + s.cancelDiagnosticsRefresh() + s.pendingFileChangesMu.Lock() + defer s.pendingFileChangesMu.Unlock() + s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ + Kind: FileChangeKindChange, + URI: uri, + Version: version, + Changes: changes, + }) +} + +func (s *Session) DidSaveFile(ctx context.Context, uri lsproto.DocumentUri) { + s.cancelDiagnosticsRefresh() + s.pendingFileChangesMu.Lock() + defer s.pendingFileChangesMu.Unlock() + s.pendingFileChanges = append(s.pendingFileChanges, FileChange{ + Kind: FileChangeKindSave, + URI: uri, + }) +} + +func (s *Session) DidChangeWatchedFiles(ctx context.Context, changes []*lsproto.FileEvent) { + fileChanges := make([]FileChange, 0, len(changes)) + for _, change := range changes { + var kind FileChangeKind + switch change.Type { + case lsproto.FileChangeTypeCreated: + kind = FileChangeKindWatchCreate + case lsproto.FileChangeTypeChanged: + kind = FileChangeKindWatchChange + case lsproto.FileChangeTypeDeleted: + kind = FileChangeKindWatchDelete + default: + continue // Ignore unknown change types. + } + fileChanges = append(fileChanges, FileChange{ + Kind: kind, + URI: change.Uri, + }) + } + + s.pendingFileChangesMu.Lock() + s.pendingFileChanges = append(s.pendingFileChanges, fileChanges...) + s.pendingFileChangesMu.Unlock() + + // Schedule a debounced snapshot update + s.ScheduleSnapshotUpdate() +} + +func (s *Session) DidChangeCompilerOptionsForInferredProjects(ctx context.Context, options *core.CompilerOptions) { + s.compilerOptionsForInferredProjects = options + s.UpdateSnapshot(ctx, s.fs.Overlays(), SnapshotChange{ + compilerOptionsForInferredProjects: options, + }) +} + +// ScheduleSnapshotUpdate schedules a debounced snapshot update. +// If there's already a pending update, it will be cancelled and a new one scheduled. +// This is useful for batching rapid changes like file watch events. +func (s *Session) ScheduleSnapshotUpdate() { + s.snapshotUpdateMu.Lock() + defer s.snapshotUpdateMu.Unlock() + + // Cancel any existing scheduled update + if s.snapshotUpdateCancel != nil { + s.snapshotUpdateCancel() + s.logger.Log("Delaying scheduled snapshot update...") + } else { + s.logger.Log("Scheduling new snapshot update...") + } + + // Create a new cancellable context for the debounce task + debounceCtx, cancel := context.WithCancel(context.Background()) + s.snapshotUpdateCancel = cancel + + // Enqueue the debounced snapshot update + s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) { + // Sleep for the debounce delay + select { + case <-time.After(s.options.DebounceDelay): + // Delay completed, proceed with update + case <-ctx.Done(): + // Context was cancelled, newer events arrived + return + } + + // Clear the cancel function since we're about to execute the update + s.snapshotUpdateMu.Lock() + s.snapshotUpdateCancel = nil + s.snapshotUpdateMu.Unlock() + + // Process the accumulated changes + changeSummary, overlays, ataChanges := s.flushChanges(context.Background()) + if !changeSummary.IsEmpty() || len(ataChanges) > 0 { + if s.options.LoggingEnabled { + s.logger.Log("Running scheduled snapshot update") + } + s.UpdateSnapshot(context.Background(), overlays, SnapshotChange{ + fileChanges: changeSummary, + ataChanges: ataChanges, + }) + } else if s.options.LoggingEnabled { + s.logger.Log("Scheduled snapshot update skipped (no changes)") + } + }) +} + +func (s *Session) ScheduleDiagnosticsRefresh() { + s.diagnosticsRefreshMu.Lock() + defer s.diagnosticsRefreshMu.Unlock() + + // Cancel any existing scheduled diagnostics refresh + if s.diagnosticsRefreshCancel != nil { + s.diagnosticsRefreshCancel() + s.logger.Log("Delaying scheduled diagnostics refresh...") + } else { + s.logger.Log("Scheduling new diagnostics refresh...") + } + + // Create a new cancellable context for the debounce task + debounceCtx, cancel := context.WithCancel(context.Background()) + s.diagnosticsRefreshCancel = cancel + + // Enqueue the debounced diagnostics refresh + s.backgroundQueue.Enqueue(debounceCtx, func(ctx context.Context) { + // Sleep for the debounce delay + select { + case <-time.After(s.options.DebounceDelay): + // Delay completed, proceed with refresh + case <-ctx.Done(): + // Context was cancelled, newer events arrived + return + } + + // Clear the cancel function since we're about to execute the refresh + s.diagnosticsRefreshMu.Lock() + s.diagnosticsRefreshCancel = nil + s.diagnosticsRefreshMu.Unlock() + + if s.options.LoggingEnabled { + s.logger.Log("Running scheduled diagnostics refresh") + } + if err := s.client.RefreshDiagnostics(context.Background()); err != nil && s.options.LoggingEnabled { + s.logger.Logf("Error refreshing diagnostics: %v", err) + } + }) +} + +func (s *Session) cancelDiagnosticsRefresh() { + s.diagnosticsRefreshMu.Lock() + defer s.diagnosticsRefreshMu.Unlock() + if s.diagnosticsRefreshCancel != nil { + s.diagnosticsRefreshCancel() + s.logger.Log("Canceled scheduled diagnostics refresh") + s.diagnosticsRefreshCancel = nil + } +} + +func (s *Session) Snapshot() (*Snapshot, func()) { + s.snapshotMu.RLock() + defer s.snapshotMu.RUnlock() + snapshot := s.snapshot + snapshot.Ref() + return snapshot, func() { + if snapshot.Deref() { + // The session itself accounts for one reference to the snapshot, and it derefs + // in UpdateSnapshot while holding the snapshotMu lock, so the only way to end + // up here is for an external caller to release the snapshot after the session + // has already dereferenced it and moved to a new snapshot. In other words, we + // can assume that `snapshot != s.snapshot`, and therefor there's no way for + // anyone else to acquire a reference to this snapshot again. + snapshot.dispose(s) + } + } +} + +func (s *Session) GetLanguageService(ctx context.Context, uri lsproto.DocumentUri) (*ls.LanguageService, error) { + var snapshot *Snapshot + fileChanges, overlays, ataChanges := s.flushChanges(ctx) + updateSnapshot := !fileChanges.IsEmpty() || len(ataChanges) > 0 + if updateSnapshot { + // If there are pending file changes, we need to update the snapshot. + // Sending the requested URI ensures that the project for this URI is loaded. + snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{ + fileChanges: fileChanges, + ataChanges: ataChanges, + requestedURIs: []lsproto.DocumentUri{uri}, + }) + } else { + // If there are no pending file changes, we can try to use the current snapshot. + s.snapshotMu.RLock() + snapshot = s.snapshot + s.snapshotMu.RUnlock() + } + + project := snapshot.GetDefaultProject(uri) + if project == nil && !updateSnapshot || project != nil && project.dirty { + // The current snapshot does not have an up to date project for the URI, + // so we need to update the snapshot to ensure the project is loaded. + // !!! Allow multiple projects to update in parallel + snapshot = s.UpdateSnapshot(ctx, overlays, SnapshotChange{requestedURIs: []lsproto.DocumentUri{uri}}) + project = snapshot.GetDefaultProject(uri) + } + if project == nil { + return nil, fmt.Errorf("no project found for URI %s", uri) + } + return ls.NewLanguageService(project, snapshot.Converters()), nil +} + +func (s *Session) UpdateSnapshot(ctx context.Context, overlays map[tspath.Path]*overlay, change SnapshotChange) *Snapshot { + // Cancel any pending scheduled update since we're doing an immediate update + s.snapshotUpdateMu.Lock() + if s.snapshotUpdateCancel != nil { + s.logger.Log("Canceling scheduled snapshot update and performing one now") + s.snapshotUpdateCancel() + s.snapshotUpdateCancel = nil + } + s.snapshotUpdateMu.Unlock() + + s.snapshotMu.Lock() + oldSnapshot := s.snapshot + newSnapshot := oldSnapshot.Clone(ctx, change, overlays, s) + s.snapshot = newSnapshot + s.snapshotMu.Unlock() + + shouldDispose := newSnapshot != oldSnapshot && oldSnapshot.Deref() + if shouldDispose { + oldSnapshot.dispose(s) + } + + // Enqueue ATA updates if needed + if s.typingsInstaller != nil { + s.triggerATAForUpdatedProjects(newSnapshot) + } + + // Enqueue logging, watch updates, and diagnostic refresh tasks + s.backgroundQueue.Enqueue(context.Background(), func(ctx context.Context) { + if s.options.LoggingEnabled { + s.logger.Write(newSnapshot.builderLogs.String()) + s.logProjectChanges(oldSnapshot, newSnapshot) + s.logger.Write("") + } + if s.options.WatchEnabled { + if err := s.updateWatches(oldSnapshot, newSnapshot); err != nil && s.options.LoggingEnabled { + s.logger.Log(err) + } + } + if change.fileChanges.IncludesWatchChangesOnly { + s.ScheduleDiagnosticsRefresh() + } + }) + + return newSnapshot +} + +// WaitForBackgroundTasks waits for all background tasks to complete. +// This is intended to be used only for testing purposes. +func (s *Session) WaitForBackgroundTasks() { + s.backgroundQueue.Wait() +} + +func updateWatch[T any](ctx context.Context, client Client, logger logging.Logger, oldWatcher, newWatcher *WatchedFiles[T]) []error { + var errors []error + if newWatcher != nil { + if id, watchers := newWatcher.Watchers(); len(watchers) > 0 { + if err := client.WatchFiles(ctx, id, watchers); err != nil { + errors = append(errors, err) + } + if logger != nil { + if oldWatcher == nil { + logger.Log(fmt.Sprintf("Added new watch: %s", id)) + } else { + logger.Log(fmt.Sprintf("Updated watch: %s", id)) + } + for _, watcher := range watchers { + logger.Log("\t" + *watcher.GlobPattern.Pattern) + } + logger.Log("") + } + } + } + if oldWatcher != nil { + if id, watchers := oldWatcher.Watchers(); len(watchers) > 0 { + if err := client.UnwatchFiles(ctx, id); err != nil { + errors = append(errors, err) + } + if logger != nil && newWatcher == nil { + logger.Log(fmt.Sprintf("Removed watch: %s", id)) + } + } + } + return errors +} + +func (s *Session) updateWatches(oldSnapshot *Snapshot, newSnapshot *Snapshot) error { + var errors []error + ctx := context.Background() + core.DiffMapsFunc( + oldSnapshot.ConfigFileRegistry.configs, + newSnapshot.ConfigFileRegistry.configs, + func(a, b *configFileEntry) bool { + return a.rootFilesWatch.ID() == b.rootFilesWatch.ID() + }, + func(_ tspath.Path, addedEntry *configFileEntry) { + errors = append(errors, updateWatch(ctx, s.client, s.logger, nil, addedEntry.rootFilesWatch)...) + }, + func(_ tspath.Path, removedEntry *configFileEntry) { + errors = append(errors, updateWatch(ctx, s.client, s.logger, removedEntry.rootFilesWatch, nil)...) + }, + func(_ tspath.Path, oldEntry, newEntry *configFileEntry) { + errors = append(errors, updateWatch(ctx, s.client, s.logger, oldEntry.rootFilesWatch, newEntry.rootFilesWatch)...) + }, + ) + + core.DiffMaps( + oldSnapshot.ProjectCollection.configuredProjects, + newSnapshot.ProjectCollection.configuredProjects, + func(_ tspath.Path, addedProject *Project) { + errors = append(errors, updateWatch(ctx, s.client, s.logger, nil, addedProject.affectingLocationsWatch)...) + errors = append(errors, updateWatch(ctx, s.client, s.logger, nil, addedProject.failedLookupsWatch)...) + }, + func(_ tspath.Path, removedProject *Project) { + errors = append(errors, updateWatch(ctx, s.client, s.logger, removedProject.affectingLocationsWatch, nil)...) + errors = append(errors, updateWatch(ctx, s.client, s.logger, removedProject.failedLookupsWatch, nil)...) + }, + func(_ tspath.Path, oldProject, newProject *Project) { + if oldProject.affectingLocationsWatch.ID() != newProject.affectingLocationsWatch.ID() { + errors = append(errors, updateWatch(ctx, s.client, s.logger, oldProject.affectingLocationsWatch, newProject.affectingLocationsWatch)...) + } + if oldProject.failedLookupsWatch.ID() != newProject.failedLookupsWatch.ID() { + errors = append(errors, updateWatch(ctx, s.client, s.logger, oldProject.failedLookupsWatch, newProject.failedLookupsWatch)...) + } + }, + ) + + if len(errors) > 0 { + return fmt.Errorf("errors updating watches: %v", errors) + } + return nil +} + +func (s *Session) Close() { + // Cancel any pending snapshot update + s.snapshotUpdateMu.Lock() + if s.snapshotUpdateCancel != nil { + s.snapshotUpdateCancel() + s.snapshotUpdateCancel = nil + } + s.snapshotUpdateMu.Unlock() + s.backgroundQueue.Close() +} + +func (s *Session) flushChanges(ctx context.Context) (FileChangeSummary, map[tspath.Path]*overlay, map[tspath.Path]*ATAStateChange) { + s.pendingFileChangesMu.Lock() + defer s.pendingFileChangesMu.Unlock() + s.pendingATAChangesMu.Lock() + defer s.pendingATAChangesMu.Unlock() + pendingATAChanges := s.pendingATAChanges + s.pendingATAChanges = make(map[tspath.Path]*ATAStateChange) + fileChanges, overlays := s.flushChangesLocked(ctx) + return fileChanges, overlays, pendingATAChanges +} + +// flushChangesLocked should only be called with s.pendingFileChangesMu held. +func (s *Session) flushChangesLocked(ctx context.Context) (FileChangeSummary, map[tspath.Path]*overlay) { + if len(s.pendingFileChanges) == 0 { + return FileChangeSummary{}, s.fs.Overlays() + } + + start := time.Now() + changes, overlays := s.fs.processChanges(s.pendingFileChanges) + if s.options.LoggingEnabled { + s.logger.Log(fmt.Sprintf("Processed %d file changes in %v", len(s.pendingFileChanges), time.Since(start))) + } + s.pendingFileChanges = nil + return changes, overlays +} + +// logProjectChanges logs information about projects that have changed between snapshots +func (s *Session) logProjectChanges(oldSnapshot *Snapshot, newSnapshot *Snapshot) { + logProject := func(project *Project) { + var builder strings.Builder + project.print(s.logger.IsVerbose() /*writeFileNames*/, s.logger.IsVerbose() /*writeFileExplanation*/, &builder) + s.logger.Log(builder.String()) + } + core.DiffMaps( + oldSnapshot.ProjectCollection.configuredProjects, + newSnapshot.ProjectCollection.configuredProjects, + func(path tspath.Path, addedProject *Project) { + // New project added + logProject(addedProject) + }, + func(path tspath.Path, removedProject *Project) { + // Project removed + s.logger.Logf("\nProject '%s' removed\n%s", removedProject.Name(), hr) + }, + func(path tspath.Path, oldProject, newProject *Project) { + // Project updated + if newProject.ProgramUpdateKind == ProgramUpdateKindNewFiles { + logProject(newProject) + } + }, + ) + + oldInferred := oldSnapshot.ProjectCollection.inferredProject + newInferred := newSnapshot.ProjectCollection.inferredProject + + if oldInferred != nil && newInferred == nil { + // Inferred project removed + s.logger.Logf("\nProject '%s' removed\n%s", oldInferred.Name(), hr) + } else if newInferred != nil && newInferred.ProgramUpdateKind == ProgramUpdateKindNewFiles { + // Inferred project updated + logProject(newInferred) + } +} + +func (s *Session) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { + return s.npmExecutor.NpmInstall(cwd, npmInstallArgs) +} + +func (s *Session) triggerATAForUpdatedProjects(newSnapshot *Snapshot) { + for _, project := range newSnapshot.ProjectCollection.Projects() { + if project.ShouldTriggerATA(newSnapshot.ID()) { + s.backgroundQueue.Enqueue(context.Background(), func(ctx context.Context) { + var logTree *logging.LogTree + if s.options.LoggingEnabled { + logTree = logging.NewLogTree("Triggering ATA for project " + project.Name()) + } + + typingsInfo := project.ComputeTypingsInfo() + request := &ata.TypingsInstallRequest{ + ProjectID: project.configFilePath, + TypingsInfo: &typingsInfo, + FileNames: core.Map(project.Program.GetSourceFiles(), func(file *ast.SourceFile) string { return file.FileName() }), + ProjectRootPath: project.currentDirectory, + CompilerOptions: project.CommandLine.CompilerOptions(), + CurrentDirectory: s.options.CurrentDirectory, + GetScriptKind: core.GetScriptKindFromFileName, + FS: s.fs.fs, + Logger: logTree, + } + + if typingsFiles, err := s.typingsInstaller.InstallTypings(request); err != nil && logTree != nil { + s.logger.Log(fmt.Sprintf("ATA installation failed for project %s: %v", project.Name(), err)) + s.logger.Log(logTree.String()) + } else { + if !slices.Equal(typingsFiles, project.typingsFiles) { + s.pendingATAChangesMu.Lock() + defer s.pendingATAChangesMu.Unlock() + s.pendingATAChanges[project.configFilePath] = &ATAStateChange{ + TypingsInfo: &typingsInfo, + TypingsFiles: typingsFiles, + Logs: logTree, + } + s.ScheduleDiagnosticsRefresh() + } + } + }) + } + } +} diff --git a/internal/project/session_test.go b/internal/project/session_test.go new file mode 100644 index 0000000000..9da57316bd --- /dev/null +++ b/internal/project/session_test.go @@ -0,0 +1,792 @@ +package project_test + +import ( + "context" + "maps" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "github.com/microsoft/typescript-go/internal/tspath" + "gotest.tools/v3/assert" +) + +func TestSession(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + defaultFiles := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + "/home/projects/TS/p1/src/x.ts": `export const x = 1;`, + "/home/projects/TS/p1/config.ts": `let x = 1, y = 2;`, + } + + t.Run("DidOpenFile", func(t *testing.T) { + t.Parallel() + t.Run("create configured project", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 0) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, defaultFiles["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + + configuredProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) + assert.Assert(t, configuredProject != nil) + + // Get language service to access the program + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil) + assert.Equal(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "export const x = 1;") + }) + + t.Run("create inferred project", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/config.ts", 1, defaultFiles["/home/projects/TS/p1/config.ts"].(string), lsproto.LanguageKindTypeScript) + + // Find tsconfig, load, notice config.ts is not included, create inferred project + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + + // Should have both configured project (for tsconfig.json) and inferred project + configuredProject := snapshot.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")) + inferredProject := snapshot.ProjectCollection.InferredProject() + assert.Assert(t, configuredProject != nil) + assert.Assert(t, inferredProject != nil) + }) + + t.Run("inferred project for in-memory files", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/config.ts", 1, defaultFiles["/home/projects/TS/p1/config.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "x", lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "untitled:Untitled-2", 1, "y", lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + }) + + t.Run("inferred project JS file", func(t *testing.T) { + t.Parallel() + jsFiles := map[string]any{ + "/home/projects/TS/p1/index.js": `import { x } from "./x";`, + } + session, _ := projecttestutil.Setup(jsFiles) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.js", 1, jsFiles["/home/projects/TS/p1/index.js"].(string), lsproto.LanguageKindJavaScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 1) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/index.js") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/index.js") != nil) + }) + }) + + t.Run("DidChangeFile", func(t *testing.T) { + t.Parallel() + t.Run("update file and program", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, defaultFiles["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + + lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + programBefore := lsBefore.GetProgram() + + session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ + Range: lsproto.Range{ + Start: lsproto.Position{ + Line: 0, + Character: 17, + }, + End: lsproto.Position{ + Line: 0, + Character: 18, + }, + }, + Text: "2", + }), + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + programAfter := lsAfter.GetProgram() + + // Program should change due to the file content change + assert.Check(t, programAfter != programBefore) + assert.Equal(t, programAfter.GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "export const x = 2;") + }) + + t.Run("unchanged source files are reused", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, defaultFiles["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + + lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + programBefore := lsBefore.GetProgram() + indexFileBefore := programBefore.GetSourceFile("/home/projects/TS/p1/src/index.ts") + + session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ + Range: lsproto.Range{ + Start: lsproto.Position{ + Line: 0, + Character: 0, + }, + End: lsproto.Position{ + Line: 0, + Character: 0, + }, + }, + Text: ";", + }), + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + programAfter := lsAfter.GetProgram() + + // Unchanged file should be reused + assert.Equal(t, programAfter.GetSourceFile("/home/projects/TS/p1/src/index.ts"), indexFileBefore) + }) + + t.Run("change can pull in new files", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p1/y.ts"] = `export const y = 2;` + session, _ := 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) + + // Verify y.ts is not initially in the program + lsBefore, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + programBefore := lsBefore.GetProgram() + assert.Check(t, programBefore.GetSourceFile("/home/projects/TS/p1/y.ts") == nil) + + session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ + Range: lsproto.Range{ + Start: lsproto.Position{ + Line: 0, + Character: 0, + }, + End: lsproto.Position{ + Line: 0, + Character: 0, + }, + }, + Text: `import { y } from "../y";\n`, + }), + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + programAfter := lsAfter.GetProgram() + + // y.ts should now be included in the program + assert.Assert(t, programAfter.GetSourceFile("/home/projects/TS/p1/y.ts") != nil) + }) + + t.Run("single-file change followed by config change reloads program", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p1/tsconfig.json"] = `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["src/index.ts"] + }` + 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() + assert.Equal(t, len(programBefore.GetSourceFiles()), 2) + + session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: ptrTo(lsproto.TextDocumentContentChangePartial{ + Range: lsproto.Range{ + Start: lsproto.Position{ + Line: 0, + Character: 0, + }, + End: lsproto.Position{ + Line: 0, + Character: 0, + }, + }, + Text: "\n", + }), + }, + }) + + err = utils.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true + }, + "include": ["./**/*"] + }`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + Uri: "file:///home/projects/TS/p1/tsconfig.json", + }, + }) + + lsAfter, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + programAfter := lsAfter.GetProgram() + assert.Equal(t, len(programAfter.GetSourceFiles()), 3) + }) + }) + + t.Run("DidCloseFile", func(t *testing.T) { + t.Parallel() + t.Run("Configured projects", func(t *testing.T) { + t.Parallel() + t.Run("delete a file, close it, recreate it", 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/x.ts", 1, files["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + assert.NilError(t, utils.FS().Remove("/home/projects/TS/p1/src/x.ts")) + + session.DidCloseFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) + + err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", "", false) + assert.NilError(t, err) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, "", lsproto.LanguageKindTypeScript) + + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil) + assert.Equal(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "") + }) + }) + + t.Run("Inferred projects", func(t *testing.T) { + t.Parallel() + t.Run("delete a file, close it, recreate it", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + delete(files, "/home/projects/TS/p1/tsconfig.json") + session, utils := projecttestutil.Setup(files) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, files["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, files["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + err := utils.FS().Remove("/home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + + session.DidCloseFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) + + err = utils.FS().WriteFile("/home/projects/TS/p1/src/x.ts", "", false) + assert.NilError(t, err) + + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, "", lsproto.LanguageKindTypeScript) + + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Assert(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") != nil) + assert.Equal(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts").Text(), "") + }) + }) + }) + + t.Run("DidSaveFile", func(t *testing.T) { + t.Parallel() + t.Run("save event first", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, defaultFiles["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, snapshot.ID(), uint64(1)) + + session.DidSaveFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + Uri: "file:///home/projects/TS/p1/src/index.ts", + }, + }) + + session.WaitForBackgroundTasks() + snapshot, release = session.Snapshot() + defer release() + // We didn't need a snapshot change, but the session overlays should be updated. + assert.Equal(t, snapshot.ID(), uint64(1)) + + // Open another file to force a snapshot update so we can see the changes. + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, defaultFiles["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, snapshot.GetFile("/home/projects/TS/p1/src/index.ts").MatchesDiskText(), true) + }) + + t.Run("watch event first", func(t *testing.T) { + t.Parallel() + session, _ := projecttestutil.Setup(defaultFiles) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts", 1, defaultFiles["/home/projects/TS/p1/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, snapshot.ID(), uint64(1)) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + Uri: "file:///home/projects/TS/p1/src/index.ts", + }, + }) + session.DidSaveFile(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + + session.WaitForBackgroundTasks() + snapshot, release = session.Snapshot() + defer release() + // We didn't need a snapshot change, but the session overlays should be updated. + assert.Equal(t, snapshot.ID(), uint64(1)) + + // Open another file to force a snapshot update so we can see the changes. + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, defaultFiles["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + snapshot, release = session.Snapshot() + defer release() + assert.Equal(t, snapshot.GetFile("/home/projects/TS/p1/src/index.ts").MatchesDiskText(), true) + }) + }) + + t.Run("Source file sharing", func(t *testing.T) { + t.Parallel() + t.Run("projects with similar options share source files", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p2/tsconfig.json"] = `{ + "compilerOptions": { + "noLib": true, + "module": "nodenext", + "strict": true, + "noCheck": true + } + }` + files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` + session, _ := 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) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p2/src/index.ts", 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + + ls1, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program1 := ls1.GetProgram() + + ls2, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p2/src/index.ts") + assert.NilError(t, err) + program2 := ls2.GetProgram() + + assert.Equal(t, + program1.GetSourceFile("/home/projects/TS/p1/src/x.ts"), + program2.GetSourceFile("/home/projects/TS/p1/src/x.ts"), + ) + }) + + t.Run("projects with different options do not share source files", func(t *testing.T) { + t.Parallel() + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p2/tsconfig.json"] = `{ + "compilerOptions": { + "module": "nodenext", + "jsx": "react" + } + }` + files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` + session, _ := 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) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p2/src/index.ts", 1, files["/home/projects/TS/p2/src/index.ts"].(string), lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + assert.Equal(t, len(snapshot.ProjectCollection.Projects()), 2) + + ls1, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program1 := ls1.GetProgram() + + ls2, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p2/src/index.ts") + assert.NilError(t, err) + program2 := ls2.GetProgram() + + x1 := program1.GetSourceFile("/home/projects/TS/p1/src/x.ts") + x2 := program2.GetSourceFile("/home/projects/TS/p1/src/x.ts") + assert.Assert(t, x1 != nil && x2 != nil) + assert.Assert(t, x1 != x2) + }) + }) + + t.Run("DidChangeWatchedFiles", func(t *testing.T) { + t.Parallel() + + t.Run("change open file", 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/x.ts", 1, files["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + 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;`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + 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) + // Program should remain the same since the file is open and changes are handled through DidChangeTextDocument + assert.Equal(t, programBefore, lsAfter.GetProgram()) + }) + + t.Run("change closed program file", 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;`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + 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) + }) + + t.Run("change config file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true, + "strict": false + } + }`, + "/home/projects/TS/p1/src/x.ts": `export declare const x: number | undefined;`, + "/home/projects/TS/p1/src/index.ts": ` + import { x } from "./x"; + let y: number = x;`, + } + + 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) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + + err = utils.FS().WriteFile("/home/projects/TS/p1/tsconfig.json", `{ + "compilerOptions": { + "noLib": false, + "strict": true + } + }`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + Uri: "file:///home/projects/TS/p1/tsconfig.json", + }, + }) + + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + }) + + t.Run("delete explicitly included file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true + }, + "files": ["src/index.ts", "src/x.ts"] + }`, + "/home/projects/TS/p1/src/x.ts": `export declare const x: number | undefined;`, + "/home/projects/TS/p1/src/index.ts": `import { x } from "./x";`, + } + 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) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + + err = utils.FS().Remove("/home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeDeleted, + Uri: "file:///home/projects/TS/p1/src/x.ts", + }, + }) + + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) + }) + + t.Run("delete wildcard included file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p1/src/index.ts": `let x = 2;`, + "/home/projects/TS/p1/src/x.ts": `let y = x;`, + } + session, utils := projecttestutil.Setup(files) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/src/x.ts", 1, files["/home/projects/TS/p1/src/x.ts"].(string), lsproto.LanguageKindTypeScript) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + program := ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/x.ts"))), 0) + + err = utils.FS().Remove("/home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeDeleted, + Uri: "file:///home/projects/TS/p1/src/index.ts", + }, + }) + + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/x.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/x.ts"))), 1) + }) + + t.Run("create explicitly included file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true + }, + "files": ["src/index.ts", "src/y.ts"] + }`, + "/home/projects/TS/p1/src/index.ts": `import { y } from "./y";`, + } + 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) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + + // Initially should have an error because y.ts is missing + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + + // Add the missing file + err = utils.FS().WriteFile("/home/projects/TS/p1/src/y.ts", `export const y = 1;`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/y.ts", + }, + }) + + // Error should be resolved + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/y.ts") != nil) + }) + + t.Run("create failed lookup location", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true + }, + "files": ["src/index.ts"] + }`, + "/home/projects/TS/p1/src/index.ts": `import { z } from "./z";`, + } + 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) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + + // Initially should have an error because z.ts is missing + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + + // Add a new file through failed lookup watch + err = utils.FS().WriteFile("/home/projects/TS/p1/src/z.ts", `export const z = 1;`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/z.ts", + }, + }) + + // Error should be resolved and the new file should be included in the program + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/z.ts") != nil) + }) + + t.Run("create wildcard included file", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": `{ + "compilerOptions": { + "noLib": true + }, + "include": ["src"] + }`, + "/home/projects/TS/p1/src/index.ts": `a;`, + } + 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) + + ls, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program := ls.GetProgram() + + // Initially should have an error because declaration for 'a' is missing + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + + // Add a new file through wildcard watch + err = utils.FS().WriteFile("/home/projects/TS/p1/src/a.ts", `const a = 1;`, false) + assert.NilError(t, err) + + session.DidChangeWatchedFiles(context.Background(), []*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeCreated, + Uri: "file:///home/projects/TS/p1/src/a.ts", + }, + }) + + // Error should be resolved and the new file should be included in the program + ls, err = session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/src/index.ts") + assert.NilError(t, err) + program = ls.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(projecttestutil.WithRequestID(t.Context()), program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/a.ts") != nil) + }) + }) +} + +func ptrTo[T any](v T) *T { + return &v +} diff --git a/internal/project/snapshot.go b/internal/project/snapshot.go new file mode 100644 index 0000000000..f0a8796f9d --- /dev/null +++ b/internal/project/snapshot.go @@ -0,0 +1,248 @@ +package project + +import ( + "context" + "fmt" + "sync/atomic" + "time" + + "github.com/microsoft/typescript-go/internal/collections" + "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/tspath" +) + +type Snapshot struct { + id uint64 + parentId uint64 + refCount atomic.Int32 + + // Session options are immutable for the server lifetime, + // so can be a pointer. + sessionOptions *SessionOptions + toPath func(fileName string) tspath.Path + converters *ls.Converters + + // Immutable state, cloned between snapshots + fs *snapshotFS + ProjectCollection *ProjectCollection + ConfigFileRegistry *ConfigFileRegistry + compilerOptionsForInferredProjects *core.CompilerOptions + + builderLogs *logging.LogTree + apiError error +} + +// NewSnapshot +func NewSnapshot( + id uint64, + fs *snapshotFS, + sessionOptions *SessionOptions, + parseCache *ParseCache, + extendedConfigCache *extendedConfigCache, + configFileRegistry *ConfigFileRegistry, + compilerOptionsForInferredProjects *core.CompilerOptions, + toPath func(fileName string) tspath.Path, +) *Snapshot { + s := &Snapshot{ + id: id, + + sessionOptions: sessionOptions, + toPath: toPath, + + fs: fs, + ConfigFileRegistry: configFileRegistry, + ProjectCollection: &ProjectCollection{toPath: toPath}, + compilerOptionsForInferredProjects: compilerOptionsForInferredProjects, + } + s.converters = ls.NewConverters(s.sessionOptions.PositionEncoding, s.LineMap) + s.refCount.Store(1) + return s +} + +func (s *Snapshot) GetDefaultProject(uri lsproto.DocumentUri) *Project { + fileName := uri.FileName() + path := s.toPath(fileName) + return s.ProjectCollection.GetDefaultProject(fileName, path) +} + +func (s *Snapshot) GetFile(fileName string) FileHandle { + return s.fs.GetFile(fileName) +} + +func (s *Snapshot) LineMap(fileName string) *ls.LineMap { + if file := s.fs.GetFile(fileName); file != nil { + return file.LineMap() + } + return nil +} + +func (s *Snapshot) Converters() *ls.Converters { + return s.converters +} + +func (s *Snapshot) ID() uint64 { + return s.id +} + +type APISnapshotRequest struct { + OpenProjects *collections.Set[string] + CloseProjects *collections.Set[tspath.Path] + UpdateProjects *collections.Set[tspath.Path] +} + +type SnapshotChange struct { + // fileChanges are the changes that have occurred since the last snapshot. + fileChanges FileChangeSummary + // requestedURIs are URIs that were requested by the client. + // The new snapshot should ensure projects for these URIs have loaded programs. + requestedURIs []lsproto.DocumentUri + // compilerOptionsForInferredProjects is the compiler options to use for inferred projects. + // It should only be set the value in the next snapshot should be changed. If nil, the + // value from the previous snapshot will be copied to the new snapshot. + compilerOptionsForInferredProjects *core.CompilerOptions + // ataChanges contains ATA-related changes to apply to projects in the new snapshot. + ataChanges map[tspath.Path]*ATAStateChange + apiRequest *APISnapshotRequest +} + +// ATAStateChange represents a change to a project's ATA state. +type ATAStateChange struct { + ProjectID tspath.Path + // TypingsInfo is the new typings info for the project. + TypingsInfo *ata.TypingsInfo + // TypingsFiles is the new list of typing files for the project. + TypingsFiles []string + Logs *logging.LogTree +} + +func (s *Snapshot) Clone(ctx context.Context, change SnapshotChange, overlays map[tspath.Path]*overlay, session *Session) *Snapshot { + var logger *logging.LogTree + if session.options.LoggingEnabled { + logger = logging.NewLogTree(fmt.Sprintf("Cloning snapshot %d", s.id)) + } + + start := time.Now() + fs := newSnapshotFSBuilder(session.fs.fs, overlays, s.fs.diskFiles, session.options.PositionEncoding, s.toPath) + fs.markDirtyFiles(change.fileChanges) + + compilerOptionsForInferredProjects := s.compilerOptionsForInferredProjects + if change.compilerOptionsForInferredProjects != nil { + // !!! mark inferred projects as dirty? + compilerOptionsForInferredProjects = change.compilerOptionsForInferredProjects + } + + newSnapshotID := session.snapshotID.Add(1) + projectCollectionBuilder := newProjectCollectionBuilder( + ctx, + newSnapshotID, + fs, + s.ProjectCollection, + s.ConfigFileRegistry, + s.ProjectCollection.apiOpenedProjects, + compilerOptionsForInferredProjects, + s.sessionOptions, + session.parseCache, + session.extendedConfigCache, + ) + + var apiError error + if change.apiRequest != nil { + apiError = projectCollectionBuilder.HandleAPIRequest(change.apiRequest, logger.Fork("HandleAPIRequest")) + } + + if len(change.ataChanges) != 0 { + projectCollectionBuilder.DidUpdateATAState(change.ataChanges, logger.Fork("DidUpdateATAState")) + } + + if !change.fileChanges.IsEmpty() { + projectCollectionBuilder.DidChangeFiles(change.fileChanges, logger.Fork("DidChangeFiles")) + } + + for _, uri := range change.requestedURIs { + projectCollectionBuilder.DidRequestFile(uri, logger.Fork("DidRequestFile")) + } + + projectCollection, configFileRegistry := projectCollectionBuilder.Finalize(logger) + snapshotFS, _ := fs.Finalize() + + newSnapshot := NewSnapshot( + newSnapshotID, + snapshotFS, + s.sessionOptions, + session.parseCache, + session.extendedConfigCache, + nil, + compilerOptionsForInferredProjects, + s.toPath, + ) + + newSnapshot.parentId = s.id + newSnapshot.ProjectCollection = projectCollection + newSnapshot.ConfigFileRegistry = configFileRegistry + newSnapshot.builderLogs = logger + newSnapshot.apiError = apiError + + for _, project := range newSnapshot.ProjectCollection.Projects() { + session.programCounter.Ref(project.Program) + if project.ProgramLastUpdate == newSnapshotID { + // If the program was updated during this clone, the project and its host are new + // and still retain references to the builder. Freezing clears the builder reference + // so it's GC'd and to ensure the project can't access any data not already in the + // snapshot during use. This is pretty kludgy, but it's an artifact of Program design: + // Program has a single host, which is expected to implement a full vfs.FS, among + // other things. That host is *mostly* only used during program *construction*, but a + // few methods may get exercised during program *use*. So, our compiler host is allowed + // to access caches and perform mutating effects (like acquire referenced project + // config files) during snapshot building, and then we call `freeze` to ensure those + // mutations don't happen afterwards. In the future, we might improve things by + // separating what it takes to build a program from what it takes to use a program, + // and only pass the former into NewProgram instead of retaining it indefinitely. + project.host.freeze(snapshotFS, newSnapshot.ConfigFileRegistry) + } + } + for path, config := range newSnapshot.ConfigFileRegistry.configs { + if config.commandLine != nil && config.commandLine.ConfigFile != nil { + if prevConfig, ok := s.ConfigFileRegistry.configs[path]; ok { + if prevConfig.commandLine != nil && config.commandLine.ConfigFile == prevConfig.commandLine.ConfigFile { + for _, file := range prevConfig.commandLine.ExtendedSourceFiles() { + // Ref count extended configs that were already loaded in the previous snapshot. + // New/changed ones were handled during config file registry building. + session.extendedConfigCache.Ref(s.toPath(file)) + } + } + } + } + } + + logger.Logf("Finished cloning snapshot %d into snapshot %d in %v", s.id, newSnapshot.id, time.Since(start)) + return newSnapshot +} + +func (s *Snapshot) Ref() { + s.refCount.Add(1) +} + +func (s *Snapshot) Deref() bool { + return s.refCount.Add(-1) == 0 +} + +func (s *Snapshot) dispose(session *Session) { + for _, project := range s.ProjectCollection.Projects() { + if project.Program != nil && session.programCounter.Deref(project.Program) { + for _, file := range project.Program.SourceFiles() { + session.parseCache.Deref(file) + } + } + } + for _, config := range s.ConfigFileRegistry.configs { + if config.commandLine != nil { + for _, file := range config.commandLine.ExtendedSourceFiles() { + session.extendedConfigCache.Deref(session.toPath(file)) + } + } + } +} diff --git a/internal/project/snapshot_test.go b/internal/project/snapshot_test.go new file mode 100644 index 0000000000..d7ebeb3267 --- /dev/null +++ b/internal/project/snapshot_test.go @@ -0,0 +1,72 @@ +package project + +import ( + "context" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs/vfstest" + "gotest.tools/v3/assert" +) + +func TestSnapshot(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + setup := func(files map[string]any) *Session { + fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) + session := NewSession(&SessionInit{ + Options: &SessionOptions{ + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: "/home/src/Library/Caches/typescript", + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: false, + LoggingEnabled: false, + }, + FS: fs, + }) + return session + } + + t.Run("compilerHost gets frozen with snapshot's FS only once", func(t *testing.T) { + t.Parallel() + files := map[string]any{ + "/home/projects/TS/p1/tsconfig.json": "{}", + "/home/projects/TS/p1/index.ts": "console.log('Hello, world!');", + } + session := setup(files) + session.DidOpenFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 1, files["/home/projects/TS/p1/index.ts"].(string), lsproto.LanguageKindTypeScript) + session.DidOpenFile(context.Background(), "untitled:Untitled-1", 1, "", lsproto.LanguageKindTypeScript) + snapshotBefore, release := session.Snapshot() + defer release() + + session.DidChangeFile(context.Background(), "file:///home/projects/TS/p1/index.ts", 2, []lsproto.TextDocumentContentChangePartialOrWholeDocument{ + { + Partial: &lsproto.TextDocumentContentChangePartial{ + Text: "\n", + Range: lsproto.Range{ + Start: lsproto.Position{Line: 0, Character: 24}, + End: lsproto.Position{Line: 0, Character: 24}, + }, + }, + }, + }) + _, err := session.GetLanguageService(context.Background(), "file:///home/projects/TS/p1/index.ts") + assert.NilError(t, err) + snapshotAfter, release := session.Snapshot() + defer release() + + // Configured project was updated by a clone + assert.Equal(t, snapshotAfter.ProjectCollection.ConfiguredProject(tspath.Path("/home/projects/ts/p1/tsconfig.json")).ProgramUpdateKind, ProgramUpdateKindCloned) + // Inferred project wasn't updated last snapshot change, so its program update kind is still NewFiles + assert.Equal(t, snapshotBefore.ProjectCollection.InferredProject(), snapshotAfter.ProjectCollection.InferredProject()) + assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().ProgramUpdateKind, ProgramUpdateKindNewFiles) + // host for inferred project should not change + assert.Equal(t, snapshotAfter.ProjectCollection.InferredProject().host.compilerFS.source, snapshotBefore.fs) + }) +} diff --git a/internal/project/snapshotfs.go b/internal/project/snapshotfs.go new file mode 100644 index 0000000000..29b51a1dfe --- /dev/null +++ b/internal/project/snapshotfs.go @@ -0,0 +1,129 @@ +package project + +import ( + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/project/dirty" + "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/vfs" + "github.com/microsoft/typescript-go/internal/vfs/cachedvfs" + "github.com/zeebo/xxh3" +) + +type FileSource interface { + FS() vfs.FS + GetFile(fileName string) FileHandle +} + +var ( + _ FileSource = (*snapshotFSBuilder)(nil) + _ FileSource = (*snapshotFS)(nil) +) + +type snapshotFS struct { + toPath func(fileName string) tspath.Path + fs vfs.FS + overlays map[tspath.Path]*overlay + diskFiles map[tspath.Path]*diskFile +} + +func (s *snapshotFS) FS() vfs.FS { + return s.fs +} + +func (s *snapshotFS) GetFile(fileName string) FileHandle { + if file, ok := s.overlays[s.toPath(fileName)]; ok { + return file + } + if file, ok := s.diskFiles[s.toPath(fileName)]; ok { + return file + } + return nil +} + +type snapshotFSBuilder struct { + fs vfs.FS + overlays map[tspath.Path]*overlay + diskFiles *dirty.SyncMap[tspath.Path, *diskFile] + toPath func(string) tspath.Path +} + +func newSnapshotFSBuilder( + fs vfs.FS, + overlays map[tspath.Path]*overlay, + diskFiles map[tspath.Path]*diskFile, + positionEncoding lsproto.PositionEncodingKind, + toPath func(fileName string) tspath.Path, +) *snapshotFSBuilder { + cachedFS := cachedvfs.From(fs) + cachedFS.Enable() + return &snapshotFSBuilder{ + fs: cachedFS, + overlays: overlays, + diskFiles: dirty.NewSyncMap(diskFiles, nil), + toPath: toPath, + } +} + +func (s *snapshotFSBuilder) FS() vfs.FS { + return s.fs +} + +func (s *snapshotFSBuilder) Finalize() (*snapshotFS, bool) { + diskFiles, changed := s.diskFiles.Finalize() + return &snapshotFS{ + fs: s.fs, + overlays: s.overlays, + diskFiles: diskFiles, + toPath: s.toPath, + }, changed +} + +func (s *snapshotFSBuilder) GetFile(fileName string) FileHandle { + path := s.toPath(fileName) + return s.GetFileByPath(fileName, path) +} + +func (s *snapshotFSBuilder) GetFileByPath(fileName string, path tspath.Path) FileHandle { + if file, ok := s.overlays[path]; ok { + return file + } + entry, _ := s.diskFiles.LoadOrStore(path, &diskFile{fileBase: fileBase{fileName: fileName}, needsReload: true}) + if entry != nil { + entry.Locked(func(entry dirty.Value[*diskFile]) { + if entry.Value() != nil && !entry.Value().MatchesDiskText() { + if content, ok := s.fs.ReadFile(fileName); ok { + entry.Change(func(file *diskFile) { + file.content = content + file.hash = xxh3.Hash128([]byte(content)) + file.needsReload = false + }) + } else { + entry.Delete() + } + } + }) + } + if entry == nil || entry.Value() == nil { + return nil + } + return entry.Value() +} + +func (s *snapshotFSBuilder) markDirtyFiles(change FileChangeSummary) { + for uri := range change.Changed.Keys() { + path := s.toPath(uri.FileName()) + if entry, ok := s.diskFiles.Load(path); ok { + entry.Change(func(file *diskFile) { + file.needsReload = true + }) + } + } + for uri := range change.Deleted.Keys() { + path := s.toPath(uri.FileName()) + if entry, ok := s.diskFiles.Load(path); ok { + entry.Change(func(file *diskFile) { + file.needsReload = true + }) + } + } +} diff --git a/internal/project/untitled_test.go b/internal/project/untitled_test.go new file mode 100644 index 0000000000..54d3cfa763 --- /dev/null +++ b/internal/project/untitled_test.go @@ -0,0 +1,161 @@ +package project_test + +import ( + "context" + "strings" + "testing" + + "github.com/microsoft/typescript-go/internal/bundled" + "github.com/microsoft/typescript-go/internal/ls" + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" + "gotest.tools/v3/assert" +) + +func TestUntitledReferences(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + // First test the URI conversion functions to understand the issue + untitledURI := lsproto.DocumentUri("untitled:Untitled-2") + convertedFileName := untitledURI.FileName() + t.Logf("URI '%s' converts to filename '%s'", untitledURI, convertedFileName) + + backToURI := ls.FileNameToDocumentURI(convertedFileName) + t.Logf("Filename '%s' converts back to URI '%s'", convertedFileName, backToURI) + + if string(backToURI) != string(untitledURI) { + t.Errorf("Round-trip conversion failed: '%s' -> '%s' -> '%s'", untitledURI, convertedFileName, backToURI) + } + + // Create a test case that simulates how untitled files should work + testContent := `let x = 42; + +x + +x++;` + + // Use the converted filename that DocumentURIToFileName would produce + untitledFileName := convertedFileName // "^/untitled/ts-nul-authority/Untitled-2" + t.Logf("Would use untitled filename: %s", untitledFileName) + + // Set up the file system with an untitled file - + // But use a regular file first to see the current behavior + files := map[string]any{ + "/Untitled-2.ts": testContent, + } + + session, _ := projecttestutil.Setup(files) + + ctx := projecttestutil.WithRequestID(context.Background()) + session.DidOpenFile(ctx, "file:///Untitled-2.ts", 1, testContent, lsproto.LanguageKindTypeScript) + + // Get language service + languageService, err := session.GetLanguageService(ctx, "file:///Untitled-2.ts") + assert.NilError(t, err) + + // Test the filename that the source file reports + program := languageService.GetProgram() + sourceFile := program.GetSourceFile("/Untitled-2.ts") + t.Logf("SourceFile.FileName() returns: '%s'", sourceFile.FileName()) + + // Call ProvideReferences using the LSP method + uri := lsproto.DocumentUri("file:///Untitled-2.ts") + lspPosition := lsproto.Position{Line: 2, Character: 0} // Line 3, character 1 (0-indexed) + + refParams := &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lspPosition, + Context: &lsproto.ReferenceContext{IncludeDeclaration: true}, + } + + resp, err := languageService.ProvideReferences(ctx, refParams) + assert.NilError(t, err) + + refs := *resp.Locations + + // Log the results + t.Logf("Input file URI: %s", uri) + t.Logf("Number of references found: %d", len(refs)) + for i, ref := range refs { + t.Logf("Reference %d: URI=%s, Range=%+v", i+1, ref.Uri, ref.Range) + } + + // We expect to find 3 references + assert.Assert(t, len(refs) == 3, "Expected 3 references, got %d", len(refs)) + + // Also test definition using ProvideDefinition + definition, err := languageService.ProvideDefinition(ctx, uri, lspPosition) + assert.NilError(t, err) + if definition.Locations != nil { + t.Logf("Definition found: %d locations", len(*definition.Locations)) + for i, loc := range *definition.Locations { + t.Logf("Definition %d: URI=%s, Range=%+v", i+1, loc.Uri, loc.Range) + } + } +} + +func TestUntitledFileInInferredProject(t *testing.T) { + t.Parallel() + if !bundled.Embedded { + t.Skip("bundled files are not embedded") + } + + // Test that untitled files are properly handled in inferred projects + testContent := `let x = 42; + +x + +x++;` + + session, _ := projecttestutil.Setup(map[string]any{}) + + ctx := projecttestutil.WithRequestID(context.Background()) + + // Open untitled files - these should create an inferred project + session.DidOpenFile(ctx, "untitled:Untitled-1", 1, "x\n\n", lsproto.LanguageKindTypeScript) + session.DidOpenFile(ctx, "untitled:Untitled-2", 1, testContent, lsproto.LanguageKindTypeScript) + + snapshot, release := session.Snapshot() + defer release() + + // Should have an inferred project + assert.Assert(t, snapshot.ProjectCollection.InferredProject() != nil) + + // Get language service for the untitled file + languageService, err := session.GetLanguageService(ctx, "untitled:Untitled-2") + assert.NilError(t, err) + + program := languageService.GetProgram() + untitledFileName := lsproto.DocumentUri("untitled:Untitled-2").FileName() + sourceFile := program.GetSourceFile(untitledFileName) + assert.Assert(t, sourceFile != nil) + assert.Equal(t, sourceFile.Text(), testContent) + + // Test references on 'x' at position 13 (line 3, after "let x = 42;\n\n") + uri := lsproto.DocumentUri("untitled:Untitled-2") + lspPosition := lsproto.Position{Line: 2, Character: 0} // Line 3, character 1 (0-indexed) + + refParams := &lsproto.ReferenceParams{ + TextDocument: lsproto.TextDocumentIdentifier{Uri: uri}, + Position: lspPosition, + Context: &lsproto.ReferenceContext{IncludeDeclaration: true}, + } + + resp, err := languageService.ProvideReferences(ctx, refParams) + assert.NilError(t, err) + + refs := *resp.Locations + t.Logf("Number of references found: %d", len(refs)) + for i, ref := range refs { + t.Logf("Reference %d: URI=%s, Range=%+v", i+1, ref.Uri, ref.Range) + // All URIs should be untitled: URIs, not file: URIs + assert.Assert(t, strings.HasPrefix(string(ref.Uri), "untitled:"), + "Expected untitled: URI, got %s", ref.Uri) + } + + // We expect to find 4 references + assert.Assert(t, len(refs) == 4, "Expected 4 references, got %d", len(refs)) +} diff --git a/internal/project/util_test.go b/internal/project/util_test.go deleted file mode 100644 index 9dd319853f..0000000000 --- a/internal/project/util_test.go +++ /dev/null @@ -1,19 +0,0 @@ -package project_test - -import ( - "testing" - - "github.com/microsoft/typescript-go/internal/project" - "github.com/microsoft/typescript-go/internal/tspath" - "gotest.tools/v3/assert" -) - -func configFileExists(t *testing.T, service *project.Service, path tspath.Path, exists bool) { - t.Helper() - _, loaded := service.ConfigFileRegistry().ConfigFiles.Load(path) - assert.Equal(t, loaded, exists, "config file %s should exist: %v", path, exists) -} - -func serviceToPath(service *project.Service, fileName string) tspath.Path { - return tspath.ToPath(fileName, service.GetCurrentDirectory(), service.FS().UseCaseSensitiveFileNames()) -} diff --git a/internal/project/validatepackagename_test.go b/internal/project/validatepackagename_test.go deleted file mode 100644 index 4d1e6762f5..0000000000 --- a/internal/project/validatepackagename_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package project_test - -import ( - "testing" - - "github.com/microsoft/typescript-go/internal/project" - "gotest.tools/v3/assert" -) - -func TestValidatePackageName(t *testing.T) { - t.Parallel() - t.Run("name cannot be too long", func(t *testing.T) { - t.Parallel() - packageName := "a" - for range 8 { - packageName += packageName - } - status, _, _ := project.ValidatePackageName(packageName) - assert.Equal(t, status, project.NameTooLong) - }) - t.Run("package name cannot start with dot", func(t *testing.T) { - t.Parallel() - status, _, _ := project.ValidatePackageName(".foo") - assert.Equal(t, status, project.NameStartsWithDot) - }) - t.Run("package name cannot start with underscore", func(t *testing.T) { - t.Parallel() - status, _, _ := project.ValidatePackageName("_foo") - assert.Equal(t, status, project.NameStartsWithUnderscore) - }) - t.Run("package non URI safe characters are not supported", func(t *testing.T) { - t.Parallel() - status, _, _ := project.ValidatePackageName(" scope ") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - status, _, _ = project.ValidatePackageName("; say ‘Hello from TypeScript!’ #") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - status, _, _ = project.ValidatePackageName("a/b/c") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - }) - t.Run("scoped package name is supported", func(t *testing.T) { - t.Parallel() - status, _, _ := project.ValidatePackageName("@scope/bar") - assert.Equal(t, status, project.NameOk) - }) - t.Run("scoped name in scoped package name cannot start with dot", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@.scope/bar") - assert.Equal(t, status, project.NameStartsWithDot) - assert.Equal(t, name, ".scope") - assert.Equal(t, isScopeName, true) - status, name, isScopeName = project.ValidatePackageName("@.scope/.bar") - assert.Equal(t, status, project.NameStartsWithDot) - assert.Equal(t, name, ".scope") - assert.Equal(t, isScopeName, true) - }) - t.Run("scoped name in scoped package name cannot start with dot", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@_scope/bar") - assert.Equal(t, status, project.NameStartsWithUnderscore) - assert.Equal(t, name, "_scope") - assert.Equal(t, isScopeName, true) - status, name, isScopeName = project.ValidatePackageName("@_scope/_bar") - assert.Equal(t, status, project.NameStartsWithUnderscore) - assert.Equal(t, name, "_scope") - assert.Equal(t, isScopeName, true) - }) - t.Run("scope name in scoped package name with non URI safe characters are not supported", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@ scope /bar") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - assert.Equal(t, name, " scope ") - assert.Equal(t, isScopeName, true) - status, name, isScopeName = project.ValidatePackageName("@; say ‘Hello from TypeScript!’ #/bar") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - assert.Equal(t, name, "; say ‘Hello from TypeScript!’ #") - assert.Equal(t, isScopeName, true) - status, name, isScopeName = project.ValidatePackageName("@ scope / bar ") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - assert.Equal(t, name, " scope ") - assert.Equal(t, isScopeName, true) - }) - t.Run("package name in scoped package name cannot start with dot", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@scope/.bar") - assert.Equal(t, status, project.NameStartsWithDot) - assert.Equal(t, name, ".bar") - assert.Equal(t, isScopeName, false) - }) - t.Run("package name in scoped package name cannot start with underscore", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@scope/_bar") - assert.Equal(t, status, project.NameStartsWithUnderscore) - assert.Equal(t, name, "_bar") - assert.Equal(t, isScopeName, false) - }) - t.Run("package name in scoped package name with non URI safe characters are not supported", func(t *testing.T) { - t.Parallel() - status, name, isScopeName := project.ValidatePackageName("@scope/ bar ") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - assert.Equal(t, name, " bar ") - assert.Equal(t, isScopeName, false) - status, name, isScopeName = project.ValidatePackageName("@scope/; say ‘Hello from TypeScript!’ #") - assert.Equal(t, status, project.NameContainsNonURISafeCharacters) - assert.Equal(t, name, "; say ‘Hello from TypeScript!’ #") - assert.Equal(t, isScopeName, false) - }) -} diff --git a/internal/project/watch.go b/internal/project/watch.go index be463cec9e..1a53be4121 100644 --- a/internal/project/watch.go +++ b/internal/project/watch.go @@ -1,15 +1,17 @@ package project import ( - "context" "fmt" - "maps" "slices" "strings" - "time" + "sync" + "sync/atomic" "github.com/microsoft/typescript-go/internal/collections" + "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/glob" "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/module" "github.com/microsoft/typescript-go/internal/tspath" ) @@ -18,93 +20,99 @@ const ( recursiveFileGlobPattern = "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}" ) -type watchFileHost interface { - Name() string - Client() Client - Log(message string) -} +type WatcherID string + +var watcherID atomic.Uint64 -type watchedFiles[T any] struct { - p watchFileHost - getGlobs func(data T) []string - watchKind lsproto.WatchKind +type WatchedFiles[T any] struct { + name string + watchKind lsproto.WatchKind + computeGlobPatterns func(input T) []string - data T - globs []string - watcherID WatcherHandle - watchType string + input T + computeWatchersOnce sync.Once + watchers []*lsproto.FileSystemWatcher + computeParsedGlobsOnce sync.Once + parsedGlobs []*glob.Glob + id uint64 } -func newWatchedFiles[T any]( - p watchFileHost, - watchKind lsproto.WatchKind, - getGlobs func(data T) []string, - watchType string, -) *watchedFiles[T] { - return &watchedFiles[T]{ - p: p, - watchKind: watchKind, - getGlobs: getGlobs, - watchType: watchType, +func NewWatchedFiles[T any](name string, watchKind lsproto.WatchKind, computeGlobPatterns func(input T) []string) *WatchedFiles[T] { + return &WatchedFiles[T]{ + id: watcherID.Add(1), + name: name, + watchKind: watchKind, + computeGlobPatterns: computeGlobPatterns, } } -func (w *watchedFiles[T]) update(ctx context.Context, newData T) { - newGlobs := w.getGlobs(newData) - newGlobs = slices.Clone(newGlobs) - slices.Sort(newGlobs) +func (w *WatchedFiles[T]) Watchers() (WatcherID, []*lsproto.FileSystemWatcher) { + w.computeWatchersOnce.Do(func() { + newWatchers := core.Map(w.computeGlobPatterns(w.input), func(glob string) *lsproto.FileSystemWatcher { + return &lsproto.FileSystemWatcher{ + GlobPattern: lsproto.PatternOrRelativePattern{ + Pattern: &glob, + }, + Kind: &w.watchKind, + } + }) + if !slices.EqualFunc(w.watchers, newWatchers, func(a, b *lsproto.FileSystemWatcher) bool { + return *a.GlobPattern.Pattern == *b.GlobPattern.Pattern + }) { + w.watchers = newWatchers + w.id = watcherID.Add(1) + } + }) + return WatcherID(fmt.Sprintf("%s watcher %d", w.name, w.id)), w.watchers +} - w.data = newData - if slices.Equal(w.globs, newGlobs) { - return +func (w *WatchedFiles[T]) ID() WatcherID { + if w == nil { + return "" } + id, _ := w.Watchers() + return id +} - w.globs = newGlobs - if w.watcherID != "" { - if err := w.p.Client().UnwatchFiles(ctx, w.watcherID); err != nil { - w.p.Log(fmt.Sprintf("%s:: Failed to unwatch %s watch: %s, err: %v newGlobs that are not updated: \n%s", w.p.Name(), w.watchType, w.watcherID, err, formatFileList(w.globs, "\t", hr))) - return - } - w.p.Log(fmt.Sprintf("%s:: %s watches unwatch %s", w.p.Name(), w.watchType, w.watcherID)) - } +func (w *WatchedFiles[T]) Name() string { + return w.name +} - w.watcherID = "" - if len(newGlobs) == 0 { - return - } +func (w *WatchedFiles[T]) WatchKind() lsproto.WatchKind { + return w.watchKind +} - watchers := make([]*lsproto.FileSystemWatcher, 0, len(newGlobs)) - for _, glob := range newGlobs { - watchers = append(watchers, &lsproto.FileSystemWatcher{ - GlobPattern: lsproto.PatternOrRelativePattern{ - Pattern: &glob, - }, - Kind: &w.watchKind, - }) - } - watcherID, err := w.p.Client().WatchFiles(ctx, watchers) - if err != nil { - w.p.Log(fmt.Sprintf("%s:: Failed to update %s watch: %v\n%s", w.p.Name(), w.watchType, err, formatFileList(w.globs, "\t", hr))) - return - } - w.watcherID = watcherID - w.p.Log(fmt.Sprintf("%s:: %s watches updated %s:\n%s", w.p.Name(), w.watchType, w.watcherID, formatFileList(w.globs, "\t", hr))) - return +func (w *WatchedFiles[T]) ParsedGlobs() []*glob.Glob { + w.computeParsedGlobsOnce.Do(func() { + patterns := w.computeGlobPatterns(w.input) + w.parsedGlobs = make([]*glob.Glob, 0, len(patterns)) + for _, pattern := range patterns { + if g, err := glob.Parse(pattern); err == nil { + w.parsedGlobs = append(w.parsedGlobs, g) + } else { + panic("failed to parse glob pattern: " + pattern) + } + } + }) + return w.parsedGlobs } -func globMapperForTypingsInstaller(data map[tspath.Path]string) []string { - return slices.AppendSeq(make([]string, 0, len(data)), maps.Values(data)) +func (w *WatchedFiles[T]) Clone(input T) *WatchedFiles[T] { + return &WatchedFiles[T]{ + name: w.name, + watchKind: w.watchKind, + computeGlobPatterns: w.computeGlobPatterns, + input: input, + parsedGlobs: w.parsedGlobs, + } } -func createResolutionLookupGlobMapper(host ProjectHost) func(data map[tspath.Path]string) []string { - rootDir := host.GetCurrentDirectory() - rootPath := tspath.ToPath(rootDir, "", host.FS().UseCaseSensitiveFileNames()) +func createResolutionLookupGlobMapper(currentDirectory string, useCaseSensitiveFileNames bool) func(data map[tspath.Path]string) []string { + rootPath := tspath.ToPath(currentDirectory, "", useCaseSensitiveFileNames) rootPathComponents := tspath.GetPathComponents(string(rootPath), "") isRootWatchable := canWatchDirectoryOrFile(rootPathComponents) return func(data map[tspath.Path]string) []string { - start := time.Now() - // dir -> recursive globSet := make(map[string]bool) var seenDirs collections.Set[string] @@ -120,11 +128,10 @@ func createResolutionLookupGlobMapper(host ProjectHost) func(data map[tspath.Pat w := getDirectoryToWatchFailedLookupLocation( fileName, path, - rootDir, + currentDirectory, rootPath, rootPathComponents, isRootWatchable, - rootDir, true, ) if w == nil { @@ -142,8 +149,7 @@ func createResolutionLookupGlobMapper(host ProjectHost) func(data map[tspath.Pat } } - timeTaken := time.Since(start) - host.Log(fmt.Sprintf("createGlobMapper took %s to create %d globs for %d failed lookups", timeTaken, len(globs), len(data))) + slices.Sort(globs) return globs } } @@ -163,17 +169,9 @@ func getDirectoryToWatchFailedLookupLocation( rootPath tspath.Path, rootPathComponents []string, isRootWatchable bool, - currentDirectory string, preferNonRecursiveWatch bool, ) *directoryOfFailedLookupWatch { failedLookupPathComponents := tspath.GetPathComponents(string(failedLookupLocationPath), "") - // Ensure failed look up is normalized path - // !!! needed? - if tspath.IsRootedDiskPath(failedLookupLocation) { - failedLookupLocation = tspath.NormalizePath(failedLookupLocation) - } else { - failedLookupLocation = tspath.GetNormalizedAbsolutePath(failedLookupLocation, currentDirectory) - } failedLookupComponents := tspath.GetPathComponents(failedLookupLocation, "") perceivedOsRootLength := perceivedOsRootLengthForWatching(failedLookupPathComponents, len(failedLookupPathComponents)) if len(failedLookupPathComponents) <= perceivedOsRootLength+1 { @@ -362,3 +360,31 @@ func isInDirectoryPath(dirComponents []string, fileOrDirComponents []string) boo func ptrTo[T any](v T) *T { return &v } + +type resolutionWithLookupLocations interface { + GetLookupLocations() *module.LookupLocations +} + +func extractLookups[T resolutionWithLookupLocations]( + projectToPath func(string) tspath.Path, + failedLookups map[tspath.Path]string, + affectingLocations map[tspath.Path]string, + cache map[tspath.Path]module.ModeAwareCache[T], +) { + for _, resolvedModulesInFile := range cache { + for _, resolvedModule := range resolvedModulesInFile { + for _, failedLookupLocation := range resolvedModule.GetLookupLocations().FailedLookupLocations { + path := projectToPath(failedLookupLocation) + if _, ok := failedLookups[path]; !ok { + failedLookups[path] = failedLookupLocation + } + } + for _, affectingLocation := range resolvedModule.GetLookupLocations().AffectingLocations { + path := projectToPath(affectingLocation) + if _, ok := affectingLocations[path]; !ok { + affectingLocations[path] = affectingLocation + } + } + } + } +} diff --git a/internal/testutil/baseline/baseline.go b/internal/testutil/baseline/baseline.go index dcaa21e19d..2c1e2413c2 100644 --- a/internal/testutil/baseline/baseline.go +++ b/internal/testutil/baseline/baseline.go @@ -13,9 +13,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/repo" "github.com/microsoft/typescript-go/internal/stringutil" - "github.com/microsoft/typescript-go/internal/tspath" "github.com/peter-evans/patience" - "gotest.tools/v3/assert" ) type Options struct { @@ -191,24 +189,16 @@ func writeComparison(t *testing.T, actualContent string, local, reference string } } - relReference, err := filepath.Rel(repo.RootPath, reference) - assert.NilError(t, err) - relReference = tspath.NormalizeSlashes(relReference) - - relLocal, err := filepath.Rel(repo.RootPath, local) - assert.NilError(t, err) - relLocal = tspath.NormalizeSlashes(relLocal) - if _, err := os.Stat(reference); err != nil { if comparingAgainstSubmodule { - t.Errorf("the baseline file %s does not exist in the TypeScript submodule", relReference) + t.Errorf("the baseline file %s does not exist in the TypeScript submodule", reference) } else { - t.Errorf("new baseline created at %s.", relLocal) + t.Errorf("new baseline created at %s.", local) } } else if comparingAgainstSubmodule { - t.Errorf("the baseline file %s does not match the reference in the TypeScript submodule", relReference) + t.Errorf("the baseline file %s does not match the reference in the TypeScript submodule", reference) } else { - t.Errorf("the baseline file %s has changed. (Run `hereby baseline-accept` if the new baseline is correct.)", relReference) + t.Errorf("the baseline file %s has changed. (Run `hereby baseline-accept` if the new baseline is correct.)", reference) } } } diff --git a/internal/testutil/projecttestutil/clientmock_generated.go b/internal/testutil/projecttestutil/clientmock_generated.go index fd2f922223..f2dff8fad5 100644 --- a/internal/testutil/projecttestutil/clientmock_generated.go +++ b/internal/testutil/projecttestutil/clientmock_generated.go @@ -24,10 +24,10 @@ var _ project.Client = &ClientMock{} // RefreshDiagnosticsFunc: func(ctx context.Context) error { // panic("mock out the RefreshDiagnostics method") // }, -// UnwatchFilesFunc: func(ctx context.Context, handle project.WatcherHandle) error { +// UnwatchFilesFunc: func(ctx context.Context, id project.WatcherID) error { // panic("mock out the UnwatchFiles method") // }, -// WatchFilesFunc: func(ctx context.Context, watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { +// WatchFilesFunc: func(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { // panic("mock out the WatchFiles method") // }, // } @@ -41,10 +41,10 @@ type ClientMock struct { RefreshDiagnosticsFunc func(ctx context.Context) error // UnwatchFilesFunc mocks the UnwatchFiles method. - UnwatchFilesFunc func(ctx context.Context, handle project.WatcherHandle) error + UnwatchFilesFunc func(ctx context.Context, id project.WatcherID) error // WatchFilesFunc mocks the WatchFiles method. - WatchFilesFunc func(ctx context.Context, watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) + WatchFilesFunc func(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error // calls tracks calls to the methods. calls struct { @@ -57,13 +57,15 @@ type ClientMock struct { UnwatchFiles []struct { // Ctx is the ctx argument value. Ctx context.Context - // Handle is the handle argument value. - Handle project.WatcherHandle + // ID is the id argument value. + ID project.WatcherID } // WatchFiles holds details about calls to the WatchFiles method. WatchFiles []struct { // Ctx is the ctx argument value. Ctx context.Context + // ID is the id argument value. + ID project.WatcherID // Watchers is the watchers argument value. Watchers []*lsproto.FileSystemWatcher } @@ -107,13 +109,13 @@ func (mock *ClientMock) RefreshDiagnosticsCalls() []struct { } // UnwatchFiles calls UnwatchFilesFunc. -func (mock *ClientMock) UnwatchFiles(ctx context.Context, handle project.WatcherHandle) error { +func (mock *ClientMock) UnwatchFiles(ctx context.Context, id project.WatcherID) error { callInfo := struct { - Ctx context.Context - Handle project.WatcherHandle + Ctx context.Context + ID project.WatcherID }{ - Ctx: ctx, - Handle: handle, + Ctx: ctx, + ID: id, } mock.lockUnwatchFiles.Lock() mock.calls.UnwatchFiles = append(mock.calls.UnwatchFiles, callInfo) @@ -122,7 +124,7 @@ func (mock *ClientMock) UnwatchFiles(ctx context.Context, handle project.Watcher var errOut error return errOut } - return mock.UnwatchFilesFunc(ctx, handle) + return mock.UnwatchFilesFunc(ctx, id) } // UnwatchFilesCalls gets all the calls that were made to UnwatchFiles. @@ -130,12 +132,12 @@ func (mock *ClientMock) UnwatchFiles(ctx context.Context, handle project.Watcher // // len(mockedClient.UnwatchFilesCalls()) func (mock *ClientMock) UnwatchFilesCalls() []struct { - Ctx context.Context - Handle project.WatcherHandle + Ctx context.Context + ID project.WatcherID } { var calls []struct { - Ctx context.Context - Handle project.WatcherHandle + Ctx context.Context + ID project.WatcherID } mock.lockUnwatchFiles.RLock() calls = mock.calls.UnwatchFiles @@ -144,25 +146,24 @@ func (mock *ClientMock) UnwatchFilesCalls() []struct { } // WatchFiles calls WatchFilesFunc. -func (mock *ClientMock) WatchFiles(ctx context.Context, watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { +func (mock *ClientMock) WatchFiles(ctx context.Context, id project.WatcherID, watchers []*lsproto.FileSystemWatcher) error { callInfo := struct { Ctx context.Context + ID project.WatcherID Watchers []*lsproto.FileSystemWatcher }{ Ctx: ctx, + ID: id, Watchers: watchers, } mock.lockWatchFiles.Lock() mock.calls.WatchFiles = append(mock.calls.WatchFiles, callInfo) mock.lockWatchFiles.Unlock() if mock.WatchFilesFunc == nil { - var ( - watcherHandleOut project.WatcherHandle - errOut error - ) - return watcherHandleOut, errOut + var errOut error + return errOut } - return mock.WatchFilesFunc(ctx, watchers) + return mock.WatchFilesFunc(ctx, id, watchers) } // WatchFilesCalls gets all the calls that were made to WatchFiles. @@ -171,10 +172,12 @@ func (mock *ClientMock) WatchFiles(ctx context.Context, watchers []*lsproto.File // len(mockedClient.WatchFilesCalls()) func (mock *ClientMock) WatchFilesCalls() []struct { Ctx context.Context + ID project.WatcherID Watchers []*lsproto.FileSystemWatcher } { var calls []struct { Ctx context.Context + ID project.WatcherID Watchers []*lsproto.FileSystemWatcher } mock.lockWatchFiles.RLock() diff --git a/internal/testutil/projecttestutil/npmexecutormock_generated.go b/internal/testutil/projecttestutil/npmexecutormock_generated.go new file mode 100644 index 0000000000..8f7ad33343 --- /dev/null +++ b/internal/testutil/projecttestutil/npmexecutormock_generated.go @@ -0,0 +1,86 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package projecttestutil + +import ( + "sync" + + "github.com/microsoft/typescript-go/internal/project/ata" +) + +// Ensure, that NpmExecutorMock does implement ata.NpmExecutor. +// If this is not the case, regenerate this file with moq. +var _ ata.NpmExecutor = &NpmExecutorMock{} + +// NpmExecutorMock is a mock implementation of ata.NpmExecutor. +// +// func TestSomethingThatUsesNpmExecutor(t *testing.T) { +// +// // make and configure a mocked ata.NpmExecutor +// mockedNpmExecutor := &NpmExecutorMock{ +// NpmInstallFunc: func(cwd string, args []string) ([]byte, error) { +// panic("mock out the NpmInstall method") +// }, +// } +// +// // use mockedNpmExecutor in code that requires ata.NpmExecutor +// // and then make assertions. +// +// } +type NpmExecutorMock struct { + // NpmInstallFunc mocks the NpmInstall method. + NpmInstallFunc func(cwd string, args []string) ([]byte, error) + + // calls tracks calls to the methods. + calls struct { + // NpmInstall holds details about calls to the NpmInstall method. + NpmInstall []struct { + // Cwd is the cwd argument value. + Cwd string + // Args is the args argument value. + Args []string + } + } + lockNpmInstall sync.RWMutex +} + +// NpmInstall calls NpmInstallFunc. +func (mock *NpmExecutorMock) NpmInstall(cwd string, args []string) ([]byte, error) { + callInfo := struct { + Cwd string + Args []string + }{ + Cwd: cwd, + Args: args, + } + mock.lockNpmInstall.Lock() + mock.calls.NpmInstall = append(mock.calls.NpmInstall, callInfo) + mock.lockNpmInstall.Unlock() + if mock.NpmInstallFunc == nil { + var ( + bytesOut []byte + errOut error + ) + return bytesOut, errOut + } + return mock.NpmInstallFunc(cwd, args) +} + +// NpmInstallCalls gets all the calls that were made to NpmInstall. +// Check the length with: +// +// len(mockedNpmExecutor.NpmInstallCalls()) +func (mock *NpmExecutorMock) NpmInstallCalls() []struct { + Cwd string + Args []string +} { + var calls []struct { + Cwd string + Args []string + } + mock.lockNpmInstall.RLock() + calls = mock.calls.NpmInstall + mock.lockNpmInstall.RUnlock() + return calls +} diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index 5acbcbe062..3d29e6b2b3 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -3,17 +3,17 @@ package projecttestutil import ( "context" "fmt" - "io" "slices" "strings" "sync" - "sync/atomic" + "testing" "github.com/microsoft/typescript-go/internal/bundled" "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/lsp/lsproto" "github.com/microsoft/typescript-go/internal/project" - "github.com/microsoft/typescript-go/internal/tspath" + "github.com/microsoft/typescript-go/internal/project/logging" + "github.com/microsoft/typescript-go/internal/testutil/baseline" "github.com/microsoft/typescript-go/internal/vfs" "github.com/microsoft/typescript-go/internal/vfs/vfstest" ) @@ -21,124 +21,96 @@ import ( //go:generate go tool github.com/matryer/moq -stub -fmt goimports -pkg projecttestutil -out clientmock_generated.go ../../project Client //go:generate go tool mvdan.cc/gofumpt -lang=go1.24 -w clientmock_generated.go -type TestTypingsInstallerOptions struct { - TypesRegistry []string - PackageToFile map[string]string - CheckBeforeNpmInstall func(cwd string, npmInstallArgs []string) -} - -type TestTypingsInstaller struct { - project.TypingsInstallerOptions - TestTypingsInstallerOptions -} - -type ProjectServiceHost struct { - fs vfs.FS - mu sync.Mutex - defaultLibraryPath string - output strings.Builder - logger *project.Logger - ClientMock *ClientMock - TestOptions *TestTypingsInstallerOptions - ServiceOptions *project.ServiceOptions -} +//go:generate go tool github.com/matryer/moq -stub -fmt goimports -pkg projecttestutil -out npmexecutormock_generated.go ../../project/ata NpmExecutor +//go:generate go tool mvdan.cc/gofumpt -lang=go1.24 -w npmexecutormock_generated.go const ( TestTypingsLocation = "/home/src/Library/Caches/typescript" - TestLibLocation = "/home/src/tslibs/TS/Lib" ) -// DefaultLibraryPath implements project.ProjectServiceHost. -func (p *ProjectServiceHost) DefaultLibraryPath() string { - return p.defaultLibraryPath +type TestTypingsInstallerOptions struct { + TypesRegistry []string + PackageToFile map[string]string } -func (p *ProjectServiceHost) TypingsLocation() string { - return TestTypingsLocation +type SessionUtils struct { + fs vfs.FS + client *ClientMock + npmExecutor *NpmExecutorMock + testOptions *TestTypingsInstallerOptions + logger logging.LogCollector } -// FS implements project.ProjectServiceHost. -func (p *ProjectServiceHost) FS() vfs.FS { - return p.fs +func (h *SessionUtils) Client() *ClientMock { + return h.client } -// GetCurrentDirectory implements project.ProjectServiceHost. -func (p *ProjectServiceHost) GetCurrentDirectory() string { - return "/" +func (h *SessionUtils) NpmExecutor() *NpmExecutorMock { + return h.npmExecutor } -// Log implements project.ProjectServiceHost. -func (p *ProjectServiceHost) Log(msg ...any) { - p.mu.Lock() - defer p.mu.Unlock() - fmt.Fprintln(&p.output, msg...) -} +func (h *SessionUtils) SetupNpmExecutorForTypingsInstaller() { + if h.testOptions == nil { + return + } -// Client implements project.ProjectServiceHost. -func (p *ProjectServiceHost) Client() project.Client { - return p.ClientMock -} + h.npmExecutor.NpmInstallFunc = func(cwd string, packageNames []string) ([]byte, error) { + // packageNames is actually npmInstallArgs due to interface misnaming + npmInstallArgs := packageNames + lenNpmInstallArgs := len(npmInstallArgs) + if lenNpmInstallArgs < 3 { + return nil, fmt.Errorf("unexpected npm install: %s %v", cwd, npmInstallArgs) + } -var _ project.ServiceHost = (*ProjectServiceHost)(nil) + if lenNpmInstallArgs == 3 && npmInstallArgs[2] == "types-registry@latest" { + // Write typings file + err := h.fs.WriteFile(cwd+"/node_modules/types-registry/index.json", h.createTypesRegistryFileContent(), false) + return nil, err + } -func Setup[FileContents any](files map[string]FileContents, testOptions *TestTypingsInstaller) (*project.Service, *ProjectServiceHost) { - host := newProjectServiceHost(files) - if testOptions != nil { - host.TestOptions = &testOptions.TestTypingsInstallerOptions - } - var throttleLimit int - if testOptions != nil && testOptions.ThrottleLimit != 0 { - throttleLimit = testOptions.ThrottleLimit - } else { - throttleLimit = 5 - } - host.ServiceOptions = &project.ServiceOptions{ - Logger: host.logger, - WatchEnabled: true, - TypingsInstallerOptions: project.TypingsInstallerOptions{ - ThrottleLimit: throttleLimit, - - NpmInstall: host.NpmInstall, - InstallStatus: make(chan project.TypingsInstallerStatus), - }, - } - service := project.NewService(host, *host.ServiceOptions) - return service, host -} + // Find the packages: they start at index 2 and continue until we hit a flag starting with -- + packageEnd := lenNpmInstallArgs + for i := 2; i < lenNpmInstallArgs; i++ { + if strings.HasPrefix(npmInstallArgs[i], "--") { + packageEnd = i + break + } + } -func (p *ProjectServiceHost) NpmInstall(cwd string, npmInstallArgs []string) ([]byte, error) { - if p.TestOptions == nil { + for _, atTypesPackageTs := range npmInstallArgs[2:packageEnd] { + // @types/packageName@TsVersionToUse + atTypesPackage := atTypesPackageTs + // Remove version suffix + if versionIndex := strings.LastIndex(atTypesPackage, "@"); versionIndex > 6 { // "@types/".length is 7, so version @ must be after + atTypesPackage = atTypesPackage[:versionIndex] + } + // Extract package name from @types/packageName + packageBaseName := atTypesPackage[7:] // Remove "@types/" prefix + content, ok := h.testOptions.PackageToFile[packageBaseName] + if !ok { + return nil, fmt.Errorf("content not provided for %s", packageBaseName) + } + err := h.fs.WriteFile(cwd+"/node_modules/@types/"+packageBaseName+"/index.d.ts", content, false) + if err != nil { + return nil, err + } + } return nil, nil } +} - lenNpmInstallArgs := len(npmInstallArgs) - if lenNpmInstallArgs < 3 { - panic(fmt.Sprintf("Unexpected npm install: %s %v", cwd, npmInstallArgs)) - } - - if lenNpmInstallArgs == 3 && npmInstallArgs[2] == "types-registry@latest" { - // Write typings file - err := p.FS().WriteFile(tspath.CombinePaths(cwd, "node_modules/types-registry/index.json"), p.createTypesRegistryFileContent(), false) - return nil, err - } +func (h *SessionUtils) FS() vfs.FS { + return h.fs +} - if p.TestOptions.CheckBeforeNpmInstall != nil { - p.TestOptions.CheckBeforeNpmInstall(cwd, npmInstallArgs) - } +func (h *SessionUtils) Logs() string { + return h.logger.String() +} - for _, atTypesPackageTs := range npmInstallArgs[2 : lenNpmInstallArgs-2] { - // @types/packageName@TsVersionToUse - packageName := atTypesPackageTs[7 : len(atTypesPackageTs)-len(project.TsVersionToUse)-1] - content, ok := p.TestOptions.PackageToFile[packageName] - if !ok { - return nil, fmt.Errorf("content not provided for %s", packageName) - } - err := p.FS().WriteFile(tspath.CombinePaths(cwd, "node_modules/@types/"+packageName+"/index.d.ts"), content, false) - if err != nil { - return nil, err - } - } - return nil, nil +func (h *SessionUtils) BaselineLogs(t *testing.T) { + baseline.Run(t, t.Name()+".log", h.Logs(), baseline.Options{ + Subfolder: "project", + }) } var ( @@ -183,16 +155,16 @@ func TypesRegistryConfig() map[string]string { return typesRegistryConfig } -func (p *ProjectServiceHost) createTypesRegistryFileContent() string { +func (h *SessionUtils) createTypesRegistryFileContent() string { var builder strings.Builder builder.WriteString("{\n \"entries\": {") - for index, entry := range p.TestOptions.TypesRegistry { - appendTypesRegistryConfig(&builder, index, entry) + for index, entry := range h.testOptions.TypesRegistry { + h.appendTypesRegistryConfig(&builder, index, entry) } - index := len(p.TestOptions.TypesRegistry) - for key := range p.TestOptions.PackageToFile { - if !slices.Contains(p.TestOptions.TypesRegistry, key) { - appendTypesRegistryConfig(&builder, index, key) + index := len(h.testOptions.TypesRegistry) + for key := range h.testOptions.PackageToFile { + if !slices.Contains(h.testOptions.TypesRegistry, key) { + h.appendTypesRegistryConfig(&builder, index, key) index++ } } @@ -200,26 +172,62 @@ func (p *ProjectServiceHost) createTypesRegistryFileContent() string { return builder.String() } -func appendTypesRegistryConfig(builder *strings.Builder, index int, entry string) { +func (h *SessionUtils) appendTypesRegistryConfig(builder *strings.Builder, index int, entry string) { if index > 0 { builder.WriteString(",") } builder.WriteString(fmt.Sprintf("\n \"%s\": {%s\n }", entry, TypesRegistryConfigText())) } -func newProjectServiceHost[FileContents any](files map[string]FileContents) *ProjectServiceHost { +func Setup(files map[string]any) (*project.Session, *SessionUtils) { + return SetupWithTypingsInstaller(files, nil) +} + +func SetupWithOptions(files map[string]any, options *project.SessionOptions) (*project.Session, *SessionUtils) { + return SetupWithOptionsAndTypingsInstaller(files, options, nil) +} + +func SetupWithTypingsInstaller(files map[string]any, tiOptions *TestTypingsInstallerOptions) (*project.Session, *SessionUtils) { + return SetupWithOptionsAndTypingsInstaller(files, nil, tiOptions) +} + +func SetupWithOptionsAndTypingsInstaller(files map[string]any, options *project.SessionOptions, tiOptions *TestTypingsInstallerOptions) (*project.Session, *SessionUtils) { fs := bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) - host := &ProjectServiceHost{ - fs: fs, - defaultLibraryPath: bundled.LibPath(), - ClientMock: &ClientMock{}, + clientMock := &ClientMock{} + npmExecutorMock := &NpmExecutorMock{} + sessionUtils := &SessionUtils{ + fs: fs, + client: clientMock, + npmExecutor: npmExecutorMock, + testOptions: tiOptions, + logger: logging.NewTestLogger(), } - var watchCount atomic.Uint32 - host.ClientMock.WatchFilesFunc = func(_ context.Context, _ []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { - return project.WatcherHandle(fmt.Sprintf("#%d", watchCount.Add(1))), nil + + // Configure the npm executor mock to handle typings installation + sessionUtils.SetupNpmExecutorForTypingsInstaller() + + // Use provided options or create default ones + sessionOptions := options + if sessionOptions == nil { + sessionOptions = &project.SessionOptions{ + CurrentDirectory: "/", + DefaultLibraryPath: bundled.LibPath(), + TypingsLocation: TestTypingsLocation, + PositionEncoding: lsproto.PositionEncodingKindUTF8, + WatchEnabled: true, + LoggingEnabled: true, + } } - host.logger = project.NewLogger([]io.Writer{&host.output}, "", project.LogLevelVerbose) - return host + + session := project.NewSession(&project.SessionInit{ + Options: sessionOptions, + FS: fs, + Client: clientMock, + NpmExecutor: npmExecutorMock, + Logger: sessionUtils.logger, + }) + + return session, sessionUtils } func WithRequestID(ctx context.Context) context.Context { diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index 4064cad038..a8d6e072a6 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -49,22 +49,14 @@ func ParseCommandLine( optionsWithAbsolutePaths := convertToOptionsWithAbsolutePaths(parser.options, CommandLineCompilerOptionsMap, host.GetCurrentDirectory()) compilerOptions := convertMapToOptions(optionsWithAbsolutePaths, &compilerOptionsParser{&core.CompilerOptions{}}).CompilerOptions watchOptions := convertMapToOptions(optionsWithAbsolutePaths, &watchOptionsParser{&core.WatchOptions{}}).WatchOptions - return &ParsedCommandLine{ - ParsedConfig: &core.ParsedOptions{ - CompilerOptions: compilerOptions, - WatchOptions: watchOptions, - FileNames: parser.fileNames, - }, - ConfigFile: nil, - Errors: parser.errors, - Raw: parser.options, // !!! keep optionsBase incase needed later. todo: figure out if this is still needed - CompileOnSave: nil, - - comparePathsOptions: tspath.ComparePathsOptions{ - UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), - CurrentDirectory: host.GetCurrentDirectory(), - }, - } + result := NewParsedCommandLine(compilerOptions, parser.fileNames, tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), + CurrentDirectory: host.GetCurrentDirectory(), + }) + result.ParsedConfig.WatchOptions = watchOptions + result.Errors = parser.errors + result.Raw = parser.options + return result } func parseCommandLineWorker( diff --git a/internal/tsoptions/parsedcommandline.go b/internal/tsoptions/parsedcommandline.go index 8d36f04811..81d6172dbe 100644 --- a/internal/tsoptions/parsedcommandline.go +++ b/internal/tsoptions/parsedcommandline.go @@ -36,6 +36,23 @@ type ParsedCommandLine struct { resolvedProjectReferencePaths []string resolvedProjectReferencePathsOnce sync.Once + + fileNamesByPath map[tspath.Path]string // maps file names to their paths, used for quick lookups + fileNamesByPathOnce sync.Once +} + +func NewParsedCommandLine( + compilerOptions *core.CompilerOptions, + rootFileNames []string, + comparePathsOptions tspath.ComparePathsOptions, +) *ParsedCommandLine { + return &ParsedCommandLine{ + ParsedConfig: &core.ParsedOptions{ + CompilerOptions: compilerOptions, + FileNames: rootFileNames, + }, + comparePathsOptions: comparePathsOptions, + } } type SourceAndProjectReference struct { @@ -158,7 +175,7 @@ func (p *ParsedCommandLine) WildcardDirectories() map[string]bool { // Normalized file names explicitly specified in `files` func (p *ParsedCommandLine) LiteralFileNames() []string { - if p.ConfigFile != nil { + if p != nil && p.ConfigFile != nil { return p.FileNames()[0:len(p.ConfigFile.configFileSpecs.validatedFilesSpec)] } return nil @@ -196,6 +213,17 @@ func (p *ParsedCommandLine) FileNames() []string { return p.ParsedConfig.FileNames } +func (p *ParsedCommandLine) FileNamesByPath() map[tspath.Path]string { + p.fileNamesByPathOnce.Do(func() { + p.fileNamesByPath = make(map[tspath.Path]string, len(p.ParsedConfig.FileNames)) + for _, fileName := range p.ParsedConfig.FileNames { + path := tspath.ToPath(fileName, p.GetCurrentDirectory(), p.UseCaseSensitiveFileNames()) + p.fileNamesByPath[path] = fileName + } + }) + return p.fileNamesByPath +} + func (p *ParsedCommandLine) ProjectReferences() []*core.ProjectReference { return p.ParsedConfig.ProjectReferences } diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 8a90a3475b..8313ee6964 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -137,6 +137,10 @@ type FileExtensionInfo struct { ScriptKind core.ScriptKind } +type ExtendedConfigCache interface { + GetExtendedConfig(fileName string, path tspath.Path, parse func() *ExtendedConfigCacheEntry) *ExtendedConfigCacheEntry +} + type ExtendedConfigCacheEntry struct { extendedResult *TsConfigSourceFile extendedConfig *parsedTsconfig @@ -668,7 +672,7 @@ func ParseJsonSourceFileConfigFileContent( configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, ) *ParsedCommandLine { // tracing?.push(tracing.Phase.Parse, "parseJsonSourceFileConfigFileContent", { path: sourceFile.fileName }); result := parseJsonConfigFileContentWorker(nil /*json*/, sourceFile, host, basePath, existingOptions, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) @@ -808,7 +812,7 @@ func convertPropertyValueToJson(sourceFile *ast.SourceFile, valueExpression *ast // jsonNode: The contents of the config file to parse // host: Instance of ParseConfigHost used to enumerate files in folder. // basePath: A root directory to resolve relative path entries in the config file to. e.g. outDir -func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry]) *ParsedCommandLine { +func ParseJsonConfigFileContent(json any, host ParseConfigHost, basePath string, existingOptions *core.CompilerOptions, configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, extendedConfigCache ExtendedConfigCache) *ParsedCommandLine { result := parseJsonConfigFileContentWorker(parseJsonToStringKey(json), nil /*sourceFile*/, host, basePath, existingOptions, configFileName, resolutionStack, extraFileExtensions, extendedConfigCache) return result } @@ -909,56 +913,48 @@ func readJsonConfigFile(fileName string, path tspath.Path, readFile func(fileNam func getExtendedConfig( sourceFile *TsConfigSourceFile, - extendedConfigPath string, + extendedConfigFileName string, host ParseConfigHost, resolutionStack []string, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, result *extendsResult, ) (*parsedTsconfig, []*ast.Diagnostic) { - path := tspath.ToPath(extendedConfigPath, host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames()) - var extendedResult *TsConfigSourceFile - var extendedConfig *parsedTsconfig var errors []*ast.Diagnostic - var cacheEntry *ExtendedConfigCacheEntry - if extendedConfigCache != nil { - entry, ok := extendedConfigCache.Load(path) - if ok && entry != nil { - cacheEntry = entry - extendedResult = cacheEntry.extendedResult - extendedConfig = cacheEntry.extendedConfig - } - } - if cacheEntry == nil { - var err []*ast.Diagnostic - extendedResult, err = readJsonConfigFile(extendedConfigPath, path, host.FS().ReadFile) + extendedConfigPath := tspath.ToPath(extendedConfigFileName, host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames()) + + parse := func() *ExtendedConfigCacheEntry { + var extendedConfig *parsedTsconfig + extendedResult, err := readJsonConfigFile(extendedConfigFileName, extendedConfigPath, host.FS().ReadFile) errors = append(errors, err...) if len(extendedResult.SourceFile.Diagnostics()) == 0 { - extendedConfig, err = parseConfig(nil, extendedResult, host, tspath.GetDirectoryPath(extendedConfigPath), tspath.GetBaseFileName(extendedConfigPath), resolutionStack, extendedConfigCache) + extendedConfig, err = parseConfig(nil, extendedResult, host, tspath.GetDirectoryPath(extendedConfigFileName), tspath.GetBaseFileName(extendedConfigFileName), resolutionStack, extendedConfigCache) errors = append(errors, err...) } - if extendedConfigCache != nil { - entry, loaded := extendedConfigCache.LoadOrStore(path, &ExtendedConfigCacheEntry{ - extendedResult: extendedResult, - extendedConfig: extendedConfig, - }) - if loaded { - // If we loaded an entry, we can use the cached result - extendedResult = entry.extendedResult - extendedConfig = entry.extendedConfig - } + return &ExtendedConfigCacheEntry{ + extendedResult: extendedResult, + extendedConfig: extendedConfig, } } + + var cacheEntry *ExtendedConfigCacheEntry + if extendedConfigCache != nil { + cacheEntry = extendedConfigCache.GetExtendedConfig(extendedConfigFileName, extendedConfigPath, parse) + } else { + cacheEntry = parse() + } + if sourceFile != nil { - result.extendedSourceFiles.Add(extendedResult.SourceFile.FileName()) - for _, extendedSourceFile := range extendedResult.ExtendedSourceFiles { + result.extendedSourceFiles.Add(cacheEntry.extendedResult.SourceFile.FileName()) + for _, extendedSourceFile := range cacheEntry.extendedResult.ExtendedSourceFiles { result.extendedSourceFiles.Add(extendedSourceFile) } } - if len(extendedResult.SourceFile.Diagnostics()) != 0 { - errors = append(errors, extendedResult.SourceFile.Diagnostics()...) + + if len(cacheEntry.extendedResult.SourceFile.Diagnostics()) != 0 { + errors = append(errors, cacheEntry.extendedResult.SourceFile.Diagnostics()...) return nil, errors } - return extendedConfig, errors + return cacheEntry.extendedConfig, errors } // parseConfig just extracts options/include/exclude/files out of a config file. @@ -970,7 +966,7 @@ func parseConfig( basePath string, configFileName string, resolutionStack []string, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, ) (*parsedTsconfig, []*ast.Diagnostic) { basePath = tspath.NormalizeSlashes(basePath) resolvedPath := tspath.GetNormalizedAbsolutePath(configFileName, basePath) @@ -1115,7 +1111,7 @@ func parseJsonConfigFileContentWorker( configFileName string, resolutionStack []tspath.Path, extraFileExtensions []FileExtensionInfo, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, ) *ParsedCommandLine { // Debug.assert((json === undefined && sourceFile !== undefined) || (json !== undefined && sourceFile === undefined)); @@ -1676,7 +1672,7 @@ func GetParsedCommandLineOfConfigFile( configFileName string, options *core.CompilerOptions, sys ParseConfigHost, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, ) (*ParsedCommandLine, []*ast.Diagnostic) { configFileName = tspath.GetNormalizedAbsolutePath(configFileName, sys.GetCurrentDirectory()) return GetParsedCommandLineOfConfigFilePath(configFileName, tspath.ToPath(configFileName, sys.GetCurrentDirectory(), sys.FS().UseCaseSensitiveFileNames()), options, sys, extendedConfigCache) @@ -1687,7 +1683,7 @@ func GetParsedCommandLineOfConfigFilePath( path tspath.Path, options *core.CompilerOptions, sys ParseConfigHost, - extendedConfigCache *collections.SyncMap[tspath.Path, *ExtendedConfigCacheEntry], + extendedConfigCache ExtendedConfigCache, ) (*ParsedCommandLine, []*ast.Diagnostic) { errors := []*ast.Diagnostic{} configFileText, errors := tryReadFile(configFileName, sys.FS().ReadFile, errors) diff --git a/internal/tspath/path.go b/internal/tspath/path.go index a0d722b233..3ff50c25b0 100644 --- a/internal/tspath/path.go +++ b/internal/tspath/path.go @@ -890,6 +890,10 @@ func ContainsPath(parent string, child string, options ComparePathsOptions) bool return true } +func (p Path) ContainsPath(child Path) bool { + return ContainsPath(string(p), string(child), ComparePathsOptions{UseCaseSensitiveFileNames: true}) +} + func FileExtensionIs(path string, extension string) bool { return len(path) > len(extension) && strings.HasSuffix(path, extension) } @@ -920,3 +924,10 @@ func ForEachAncestorDirectoryPath[T any](directory Path, callback func(directory func HasExtension(fileName string) bool { return strings.Contains(GetBaseFileName(fileName), ".") } + +func SplitVolumePath(path string) (volume string, rest string, ok bool) { + if len(path) >= 2 && IsVolumeCharacter(path[0]) && path[1] == ':' { + return strings.ToLower(path[0:2]), path[2:], true + } + return "", path, false +}