diff --git a/internal/api/api.go b/internal/api/api.go index 6b04c741aa..8279e2481d 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -119,6 +119,11 @@ func (api *API) PositionEncoding() lsproto.PositionEncodingKind { return lsproto.PositionEncodingKindUTF8 } +// Client implements ProjectHost. +func (api *API) Client() project.Client { + return nil +} + func (api *API) HandleRequest(id int, method string, payload []byte) ([]byte, error) { params, err := unmarshalPayload(method, payload) if err != nil { @@ -351,7 +356,7 @@ func (api *API) getOrCreateScriptInfo(fileName string, path tspath.Path, scriptK if !ok { return nil } - info = project.NewScriptInfo(fileName, path, scriptKind) + info = project.NewScriptInfo(fileName, path, scriptKind, api.host.FS()) info.SetTextFromDisk(content) api.scriptInfosMu.Lock() defer api.scriptInfosMu.Unlock() diff --git a/internal/lsp/lsproto/jsonrpc.go b/internal/lsp/lsproto/jsonrpc.go index 90763c4991..af9626082c 100644 --- a/internal/lsp/lsproto/jsonrpc.go +++ b/internal/lsp/lsproto/jsonrpc.go @@ -28,6 +28,10 @@ type ID struct { int int32 } +func NewIDString(str string) *ID { + return &ID{str: str} +} + func (id *ID) MarshalJSON() ([]byte, error) { if id.str != "" { return json.Marshal(id.str) @@ -43,6 +47,13 @@ func (id *ID) UnmarshalJSON(data []byte) error { return json.Unmarshal(data, &id.int) } +func (id *ID) TryInt() (int32, bool) { + if id == nil || id.str != "" { + return 0, false + } + return id.int, true +} + func (id *ID) MustInt() int32 { if id.str != "" { panic("ID is not an integer") @@ -54,11 +65,20 @@ func (id *ID) MustInt() int32 { type RequestMessage struct { JSONRPC JSONRPCVersion `json:"jsonrpc"` - ID *ID `json:"id"` + ID *ID `json:"id,omitempty"` Method Method `json:"method"` Params any `json:"params"` } +func NewRequestMessage(method Method, id *ID, params any) *RequestMessage { + return &RequestMessage{ + JSONRPC: JSONRPCVersion{}, + ID: id, + Method: method, + Params: params, + } +} + func (r *RequestMessage) UnmarshalJSON(data []byte) error { var raw struct { JSONRPC JSONRPCVersion `json:"jsonrpc"` diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 14f2957cbc..1da871f573 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -40,10 +40,14 @@ func NewServer(opts *ServerOptions) *Server { newLine: opts.NewLine, fs: opts.FS, defaultLibraryPath: opts.DefaultLibraryPath, + watchers: make(map[project.WatcherHandle]struct{}), } } -var _ project.ServiceHost = (*Server)(nil) +var ( + _ project.ServiceHost = (*Server)(nil) + _ project.Client = (*Server)(nil) +) type Server struct { r *lsproto.BaseReader @@ -51,6 +55,7 @@ type Server struct { stderr io.Writer + clientSeq int32 requestMethod string requestTime time.Time @@ -62,36 +67,95 @@ type Server struct { initializeParams *lsproto.InitializeParams positionEncoding lsproto.PositionEncodingKind + watcheEnabled bool + watcherID int + watchers map[project.WatcherHandle]struct{} logger *project.Logger projectService *project.Service converters *ls.Converters } -// FS implements project.ProjectServiceHost. +// FS implements project.ServiceHost. func (s *Server) FS() vfs.FS { return s.fs } -// DefaultLibraryPath implements project.ProjectServiceHost. +// DefaultLibraryPath implements project.ServiceHost. func (s *Server) DefaultLibraryPath() string { return s.defaultLibraryPath } -// GetCurrentDirectory implements project.ProjectServiceHost. +// GetCurrentDirectory implements project.ServiceHost. func (s *Server) GetCurrentDirectory() string { return s.cwd } -// NewLine implements project.ProjectServiceHost. +// NewLine implements project.ServiceHost. func (s *Server) NewLine() string { return s.newLine.GetNewLineCharacter() } -// Trace implements project.ProjectServiceHost. +// 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.watcheEnabled { + return nil + } + return s +} + +// WatchFiles implements project.Client. +func (s *Server) WatchFiles(watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) { + watcherId := fmt.Sprintf("watcher-%d", s.watcherID) + if err := s.sendRequest(lsproto.MethodClientRegisterCapability, &lsproto.RegistrationParams{ + Registrations: []*lsproto.Registration{ + { + Id: watcherId, + Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles), + RegisterOptions: ptrTo(any(lsproto.DidChangeWatchedFilesRegistrationOptions{ + Watchers: watchers, + })), + }, + }, + }); err != nil { + return "", fmt.Errorf("failed to register file watcher: %w", err) + } + + handle := project.WatcherHandle(watcherId) + s.watchers[handle] = struct{}{} + s.watcherID++ + return handle, nil +} + +// UnwatchFiles implements project.Client. +func (s *Server) UnwatchFiles(handle project.WatcherHandle) error { + if _, ok := s.watchers[handle]; ok { + if err := s.sendRequest(lsproto.MethodClientUnregisterCapability, &lsproto.UnregistrationParams{ + Unregisterations: []*lsproto.Unregistration{ + { + Id: string(handle), + Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles), + }, + }, + }); err != nil { + return fmt.Errorf("failed to unregister file watcher: %w", err) + } + delete(s.watchers, handle) + return nil + } + + return fmt.Errorf("no file watcher exists with ID %s", handle) +} + +// PublishDiagnostics implements project.Client. +func (s *Server) PublishDiagnostics(params *lsproto.PublishDiagnosticsParams) error { + return s.sendNotification(lsproto.MethodTextDocumentPublishDiagnostics, params) +} + func (s *Server) Run() error { for { req, err := s.read() @@ -105,6 +169,11 @@ func (s *Server) Run() error { return err } + // TODO: handle response messages + if req == nil { + continue + } + if s.initializeParams == nil { if req.Method == lsproto.MethodInitialize { if err := s.handleInitialize(req); err != nil { @@ -132,12 +201,37 @@ func (s *Server) read() (*lsproto.RequestMessage, error) { req := &lsproto.RequestMessage{} if err := json.Unmarshal(data, req); err != nil { + res := &lsproto.ResponseMessage{} + if err = json.Unmarshal(data, res); err == nil { + // !!! TODO: handle response + return nil, nil + } return nil, fmt.Errorf("%w: %w", lsproto.ErrInvalidRequest, err) } return req, nil } +func (s *Server) sendRequest(method lsproto.Method, params any) error { + s.clientSeq++ + id := lsproto.NewIDString(fmt.Sprintf("ts%d", s.clientSeq)) + req := lsproto.NewRequestMessage(method, id, params) + data, err := json.Marshal(req) + if err != nil { + return err + } + return s.w.Write(data) +} + +func (s *Server) sendNotification(method lsproto.Method, params any) error { + req := lsproto.NewRequestMessage(method, nil /*id*/, params) + data, err := json.Marshal(req) + if err != nil { + return err + } + return s.w.Write(data) +} + func (s *Server) sendResult(id *lsproto.ID, result any) error { return s.sendResponse(&lsproto.ResponseMessage{ ID: id, @@ -189,6 +283,8 @@ func (s *Server) handleMessage(req *lsproto.RequestMessage) error { return s.handleDidSave(req) case *lsproto.DidCloseTextDocumentParams: return s.handleDidClose(req) + case *lsproto.DidChangeWatchedFilesParams: + return s.handleDidChangeWatchedFiles(req) case *lsproto.DocumentDiagnosticParams: return s.handleDocumentDiagnostic(req) case *lsproto.HoverParams: @@ -262,9 +358,14 @@ func (s *Server) handleInitialize(req *lsproto.RequestMessage) error { } func (s *Server) handleInitialized(req *lsproto.RequestMessage) error { + if s.initializeParams.Capabilities.Workspace.DidChangeWatchedFiles != nil && *s.initializeParams.Capabilities.Workspace.DidChangeWatchedFiles.DynamicRegistration { + s.watcheEnabled = true + } + s.logger = project.NewLogger([]io.Writer{s.stderr}, "" /*file*/, project.LogLevelVerbose) s.projectService = project.NewService(s, project.ServiceOptions{ Logger: s.logger, + WatchEnabled: s.watcheEnabled, PositionEncoding: s.positionEncoding, }) @@ -322,6 +423,11 @@ func (s *Server) handleDidClose(req *lsproto.RequestMessage) error { return nil } +func (s *Server) handleDidChangeWatchedFiles(req *lsproto.RequestMessage) error { + params := req.Params.(*lsproto.DidChangeWatchedFilesParams) + return s.projectService.OnWatchedFilesChanged(params.Changes) +} + func (s *Server) handleDocumentDiagnostic(req *lsproto.RequestMessage) error { params := req.Params.(*lsproto.DocumentDiagnosticParams) file, project := s.getFileAndProject(params.TextDocument.Uri) diff --git a/internal/project/documentregistry.go b/internal/project/documentregistry.go index f4a651e48c..d10786ba58 100644 --- a/internal/project/documentregistry.go +++ b/internal/project/documentregistry.go @@ -92,9 +92,9 @@ func (r *DocumentRegistry) getDocumentWorker( 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.sourceFile.Version != scriptInfo.version { + if entry.sourceFile.Version != scriptInfo.Version() { sourceFile := parser.ParseSourceFile(scriptInfo.fileName, scriptInfo.path, scriptInfo.text, scriptTarget, scanner.JSDocParsingModeParseAll) - sourceFile.Version = scriptInfo.version + sourceFile.Version = scriptInfo.Version() entry.mu.Lock() defer entry.mu.Unlock() entry.sourceFile = sourceFile @@ -104,7 +104,7 @@ func (r *DocumentRegistry) getDocumentWorker( } else { // Have never seen this file with these settings. Create a new source file for it. sourceFile := parser.ParseSourceFile(scriptInfo.fileName, scriptInfo.path, scriptInfo.text, scriptTarget, scanner.JSDocParsingModeParseAll) - sourceFile.Version = scriptInfo.version + sourceFile.Version = scriptInfo.Version() entry, _ := r.documents.LoadOrStore(key, ®istryEntry{ sourceFile: sourceFile, refCount: 0, diff --git a/internal/project/host.go b/internal/project/host.go index 9c8843c0e3..b4dbb9dafa 100644 --- a/internal/project/host.go +++ b/internal/project/host.go @@ -1,10 +1,23 @@ package project -import "github.com/microsoft/typescript-go/internal/vfs" +import ( + "github.com/microsoft/typescript-go/internal/lsp/lsproto" + "github.com/microsoft/typescript-go/internal/vfs" +) + +type WatcherHandle string + +type Client interface { + WatchFiles(watchers []*lsproto.FileSystemWatcher) (WatcherHandle, error) + UnwatchFiles(handle WatcherHandle) error + PublishDiagnostics(params *lsproto.PublishDiagnosticsParams) error +} type ServiceHost interface { FS() vfs.FS DefaultLibraryPath() string GetCurrentDirectory() string NewLine() string + + Client() Client } diff --git a/internal/project/project.go b/internal/project/project.go index 3aa4e4751e..1ac6c64b55 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -2,6 +2,8 @@ package project import ( "fmt" + "maps" + "slices" "strings" "sync" @@ -17,6 +19,7 @@ import ( ) //go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -output=project_stringer_generated.go +const hr = "-----------------------------------------------" var projectNamer = &namer{} @@ -41,6 +44,8 @@ type ProjectHost interface { OnDiscoveredSymlink(info *ScriptInfo) Log(s string) PositionEncoding() lsproto.PositionEncodingKind + + Client() Client } type Project struct { @@ -56,9 +61,10 @@ type Project struct { hasAddedOrRemovedFiles bool hasAddedOrRemovedSymlinks bool deferredClose bool - reloadConfig bool + pendingConfigReload bool - currentDirectory string + comparePathsOptions tspath.ComparePathsOptions + currentDirectory string // Inferred projects only rootPath tspath.Path @@ -66,10 +72,16 @@ type Project struct { 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] - compilerOptions *core.CompilerOptions - languageService *ls.LanguageService - program *compiler.Program + rootFileNames *collections.OrderedMap[tspath.Path, string] + compilerOptions *core.CompilerOptions + parsedCommandLine *tsoptions.ParsedCommandLine + languageService *ls.LanguageService + program *compiler.Program + + // Watchers + rootFilesWatch *watchedFiles[[]string] + failedLookupsWatch *watchedFiles[map[tspath.Path]string] + affectingLocationsWatch *watchedFiles[map[tspath.Path]string] } func NewConfiguredProject(configFileName string, configFilePath tspath.Path, host ProjectHost) *Project { @@ -77,6 +89,10 @@ func NewConfiguredProject(configFileName string, configFilePath tspath.Path, hos project.configFileName = configFileName project.configFilePath = configFilePath project.initialLoadPending = true + client := host.Client() + if client != nil { + project.rootFilesWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity) + } return project } @@ -96,6 +112,19 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos currentDirectory: currentDirectory, rootFileNames: &collections.OrderedMap[tspath.Path, string]{}, } + project.comparePathsOptions = tspath.ComparePathsOptions{ + CurrentDirectory: currentDirectory, + UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), + } + client := host.Client() + if client != nil { + project.failedLookupsWatch = newWatchedFiles(client, lsproto.WatchKindCreate, func(data map[tspath.Path]string) []string { + return slices.Sorted(maps.Values(data)) + }) + project.affectingLocationsWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, func(data map[tspath.Path]string) []string { + return slices.Sorted(maps.Values(data)) + }) + } project.languageService = ls.NewLanguageService(project) project.markAsDirty() return project @@ -205,6 +234,95 @@ func (p *Project) LanguageService() *ls.LanguageService { return p.languageService } +func (p *Project) getRootFileWatchGlobs() []string { + if p.kind == KindConfigured { + wildcardDirectories := p.parsedCommandLine.WildcardDirectories() + result := make([]string, 0, len(wildcardDirectories)+1) + result = append(result, p.configFileName) + for dir, recursive := range wildcardDirectories { + result = append(result, fmt.Sprintf("%s/%s", dir, core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern))) + } + return result + } + return nil +} + +func (p *Project) getModuleResolutionWatchGlobs() (failedLookups map[tspath.Path]string, affectingLocaions map[tspath.Path]string) { + failedLookups = make(map[tspath.Path]string) + affectingLocaions = make(map[tspath.Path]string) + for _, resolvedModulesInFile := range p.program.GetResolvedModules() { + for _, resolvedModule := range resolvedModulesInFile { + for _, failedLookupLocation := range resolvedModule.FailedLookupLocations { + path := p.toPath(failedLookupLocation) + if _, ok := failedLookups[path]; !ok { + failedLookups[path] = failedLookupLocation + } + } + for _, affectingLocation := range resolvedModule.AffectingLocations { + path := p.toPath(affectingLocation) + if _, ok := affectingLocaions[path]; !ok { + affectingLocaions[path] = affectingLocation + } + } + } + } + return failedLookups, affectingLocaions +} + +func (p *Project) updateWatchers() { + client := p.host.Client() + if client == nil { + return + } + + rootFileGlobs := p.getRootFileWatchGlobs() + failedLookupGlobs, affectingLocationGlobs := p.getModuleResolutionWatchGlobs() + + if rootFileGlobs != nil { + if updated, err := p.rootFilesWatch.update(rootFileGlobs); err != nil { + p.log(fmt.Sprintf("Failed to update root file watch: %v", err)) + } else if updated { + p.log("Root file watches updated:\n" + formatFileList(rootFileGlobs, "\t", hr)) + } + } + + if updated, err := p.failedLookupsWatch.update(failedLookupGlobs); err != nil { + p.log(fmt.Sprintf("Failed to update failed lookup watch: %v", err)) + } else if updated { + p.log("Failed lookup watches updated:\n" + formatFileList(p.failedLookupsWatch.globs, "\t", hr)) + } + + if updated, err := p.affectingLocationsWatch.update(affectingLocationGlobs); err != nil { + p.log(fmt.Sprintf("Failed to update affecting location watch: %v", err)) + } else if updated { + p.log("Affecting location watches updated:\n" + formatFileList(p.affectingLocationsWatch.globs, "\t", hr)) + } +} + +// 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 p.kind == KindConfigured { + if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName, p.comparePathsOptions) { + p.pendingConfigReload = true + p.markAsDirty() + return + } + } + + if _, ok := p.failedLookupsWatch.data[path]; ok { + p.markAsDirty() + } else if _, ok := p.affectingLocationsWatch.data[path]; ok { + p.markAsDirty() + } +} + func (p *Project) getOrCreateScriptInfoAndAttachToProject(fileName string, scriptKind core.ScriptKind) *ScriptInfo { if scriptInfo := p.host.GetOrCreateScriptInfoForFile(fileName, p.toPath(fileName), scriptKind); scriptInfo != nil { scriptInfo.attachToProject(p) @@ -232,6 +350,7 @@ func (p *Project) markAsDirty() { } } +// updateIfDirty returns true if the project was updated. func (p *Project) updateIfDirty() bool { // !!! p.invalidateResolutionsOfFailedLookupLocations() return p.dirty && p.updateGraph() @@ -257,11 +376,11 @@ func (p *Project) updateGraph() bool { hasAddedOrRemovedFiles := p.hasAddedOrRemovedFiles p.initialLoadPending = false - if p.kind == KindConfigured && p.reloadConfig { + if p.kind == KindConfigured && p.pendingConfigReload { if err := p.LoadConfig(); err != nil { panic(fmt.Sprintf("failed to reload config: %v", err)) } - p.reloadConfig = false + p.pendingConfigReload = false } p.hasAddedOrRemovedFiles = false @@ -283,6 +402,7 @@ func (p *Project) updateGraph() bool { } } + p.updateWatchers() return true } @@ -324,7 +444,7 @@ func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProjec case KindInferred: p.rootFileNames.Delete(info.path) case KindConfigured: - p.reloadConfig = true + p.pendingConfigReload = true } } @@ -384,6 +504,7 @@ func (p *Project) LoadConfig() error { }, " ", " ")), ) + p.parsedCommandLine = parsedCommandLine p.compilerOptions = parsedCommandLine.CompilerOptions() p.setRootFiles(parsedCommandLine.FileNames()) } else { @@ -399,16 +520,21 @@ func (p *Project) setRootFiles(rootFileNames []string) { newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames)) for _, file := range rootFileNames { scriptKind := p.getScriptKind(file) - scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, p.toPath(file), scriptKind) - newRootScriptInfos[scriptInfo.path] = struct{}{} - if _, isRoot := p.rootFileNames.Get(scriptInfo.path); !isRoot { + path := p.toPath(file) + // !!! updateNonInferredProjectFiles uses a fileExists check, which I guess + // could be needed if a watcher fails? + scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, path, scriptKind) + newRootScriptInfos[path] = struct{}{} + isAlreadyRoot := p.rootFileNames.Has(path) + + if !isAlreadyRoot && scriptInfo != nil { p.addRoot(scriptInfo) if scriptInfo.isOpen { // !!! // s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo) } - } else { - p.rootFileNames.Set(scriptInfo.path, file) + } else if !isAlreadyRoot { + p.rootFileNames.Set(path, file) } } @@ -451,7 +577,7 @@ func (p *Project) print(writeFileNames bool, writeFileExplanation bool, writeFil // if writeFileExplanation {} } } - builder.WriteString("-----------------------------------------------") + builder.WriteString(hr) return builder.String() } @@ -466,3 +592,19 @@ func (p *Project) logf(format string, args ...interface{}) { func (p *Project) Close() { // !!! } + +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 + } + 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/scriptinfo.go b/internal/project/scriptinfo.go index f97f15ad13..dfa18a67af 100644 --- a/internal/project/scriptinfo.go +++ b/internal/project/scriptinfo.go @@ -27,9 +27,11 @@ type ScriptInfo struct { deferredDelete bool containingProjects []*Project + + fs vfs.FS } -func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind) *ScriptInfo { +func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind, fs vfs.FS) *ScriptInfo { isDynamic := isDynamicFileName(fileName) realpath := core.IfElse(isDynamic, path, "") return &ScriptInfo{ @@ -38,6 +40,7 @@ func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind realpath: realpath, isDynamic: isDynamic, scriptKind: scriptKind, + fs: fs, } } @@ -51,15 +54,29 @@ func (s *ScriptInfo) Path() tspath.Path { func (s *ScriptInfo) LineMap() *ls.LineMap { if s.lineMap == nil { - s.lineMap = ls.ComputeLineStarts(s.text) + 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) reloadIfNeeded() { + if s.pendingReloadFromDisk { + if newText, ok := s.fs.ReadFile(s.fileName); ok { + s.SetTextFromDisk(newText) + } + } +} + func (s *ScriptInfo) open(newText string) { s.isOpen = true s.pendingReloadFromDisk = false @@ -133,7 +150,7 @@ func (s *ScriptInfo) isOrphan() bool { } func (s *ScriptInfo) editContent(change ls.TextChange) { - s.setText(change.ApplyTo(s.text)) + s.setText(change.ApplyTo(s.Text())) s.markContainingProjectsAsDirty() } diff --git a/internal/project/service.go b/internal/project/service.go index 6fe70c8a95..3cd69e61d0 100644 --- a/internal/project/service.go +++ b/internal/project/service.go @@ -2,6 +2,7 @@ package project import ( "fmt" + "slices" "strings" "sync" @@ -30,6 +31,7 @@ type assignProjectResult struct { type ServiceOptions struct { Logger *Logger PositionEncoding lsproto.PositionEncodingKind + WatchEnabled bool } var _ ProjectHost = (*Service)(nil) @@ -38,6 +40,7 @@ type Service struct { host ServiceHost options ServiceOptions comparePathsOptions tspath.ComparePathsOptions + converters *ls.Converters configuredProjects map[tspath.Path]*Project // unrootedInferredProject is the inferred project for files opened without a projectRootDirectory @@ -61,7 +64,7 @@ type Service struct { 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()) - return &Service{ + service := &Service{ host: host, options: options, comparePathsOptions: tspath.ComparePathsOptions{ @@ -82,6 +85,12 @@ func NewService(host ServiceHost, options ServiceOptions) *Service { filenameToScriptInfoVersion: make(map[tspath.Path]int), realpathToScriptInfos: make(map[tspath.Path]map[*ScriptInfo]struct{}), } + + service.converters = ls.NewConverters(options.PositionEncoding, func(fileName string) ls.ScriptInfo { + return service.GetScriptInfo(fileName) + }) + + return service } // GetCurrentDirectory implements ProjectHost. @@ -124,6 +133,14 @@ func (s *Service) PositionEncoding() lsproto.PositionEncodingKind { return s.options.PositionEncoding } +// Client implements ProjectHost. +func (s *Service) Client() Client { + if s.options.WatchEnabled { + return s.host.Client() + } + return nil +} + func (s *Service) Projects() []*Project { projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects)) for _, project := range s.configuredProjects { @@ -215,6 +232,94 @@ func (s *Service) SourceFileCount() int { return s.documentRegistry.size() } +func (s *Service) OnWatchedFilesChanged(changes []*lsproto.FileEvent) error { + for _, change := range changes { + fileName := ls.DocumentURIToFileName(change.Uri) + path := s.toPath(fileName) + if project, ok := s.configuredProjects[path]; ok { + // tsconfig of project + if err := s.onConfigFileChanged(project, change.Type); 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.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.configuredProjects { + if project.updateIfDirty() { + if err := s.publishDiagnosticsForOpenFiles(project); err != nil { + return err + } + } + } + return nil +} + +func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileChangeType) error { + wasDeferredClose := project.deferredClose + switch changeKind { + case lsproto.FileChangeTypeCreated: + if wasDeferredClose { + project.deferredClose = false + } + case lsproto.FileChangeTypeDeleted: + project.deferredClose = true + } + + s.delayUpdateProjectGraph(project) + if !project.deferredClose { + project.pendingConfigReload = true + project.markAsDirty() + } + return nil +} + +func (s *Service) publishDiagnosticsForOpenFiles(project *Project) error { + client := s.host.Client() + if client == nil { + return nil + } + + for path := range s.openFiles { + info := s.GetScriptInfoByPath(path) + if slices.Contains(info.containingProjects, project) { + diagnostics := project.LanguageService().GetDocumentDiagnostics(info.fileName) + lspDiagnostics := make([]*lsproto.Diagnostic, len(diagnostics)) + for i, diagnostic := range diagnostics { + if diag, err := s.converters.ToLSPDiagnostic(diagnostic); err != nil { + return fmt.Errorf("error converting diagnostic: %w", err) + } else { + lspDiagnostics[i] = diag + } + } + + if err := client.PublishDiagnostics(&lsproto.PublishDiagnosticsParams{ + Uri: ls.FileNameToDocumentURI(info.fileName), + Diagnostics: lspDiagnostics, + }); err != nil { + return fmt.Errorf("error publishing diagnostics: %w", err) + } + } + } + return nil +} + func (s *Service) ensureProjectStructureUpToDate() { var hasChanges bool for _, project := range s.configuredProjects { @@ -351,7 +456,7 @@ func (s *Service) getOrCreateScriptInfoWorker(fileName string, path tspath.Path, } } - info = NewScriptInfo(fileName, path, scriptKind) + info = NewScriptInfo(fileName, path, scriptKind, s.host.FS()) if fromDisk { info.SetTextFromDisk(fileContent) } diff --git a/internal/project/service_test.go b/internal/project/service_test.go index 02bd34c484..d60201ce6c 100644 --- a/internal/project/service_test.go +++ b/internal/project/service_test.go @@ -7,6 +7,7 @@ import ( "github.com/microsoft/typescript-go/internal/bundled" "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" "github.com/microsoft/typescript-go/internal/testutil/projecttestutil" "gotest.tools/v3/assert" @@ -18,7 +19,7 @@ func TestService(t *testing.T) { t.Skip("bundled files are not embedded") } - files := map[string]string{ + defaultFiles := map[string]string{ "/home/projects/TS/p1/tsconfig.json": `{ "compilerOptions": { "noLib": true, @@ -36,9 +37,9 @@ func TestService(t *testing.T) { t.Parallel() t.Run("create configured project", func(t *testing.T) { t.Parallel() - service, _ := projecttestutil.Setup(files) + service, _ := projecttestutil.Setup(defaultFiles) assert.Equal(t, len(service.Projects()), 0) - service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") + 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) @@ -49,8 +50,8 @@ func TestService(t *testing.T) { t.Run("create inferred project", func(t *testing.T) { t.Parallel() - service, _ := projecttestutil.Setup(files) - service.OpenFile("/home/projects/TS/p1/config.ts", files["/home/projects/TS/p1/config.ts"], core.ScriptKindTS, "") + service, _ := projecttestutil.Setup(defaultFiles) + 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") @@ -59,8 +60,8 @@ func TestService(t *testing.T) { t.Run("inferred project for in-memory files", func(t *testing.T) { t.Parallel() - service, _ := projecttestutil.Setup(files) - service.OpenFile("/home/projects/TS/p1/config.ts", files["/home/projects/TS/p1/config.ts"], core.ScriptKindTS, "") + service, _ := projecttestutil.Setup(defaultFiles) + 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) @@ -76,8 +77,8 @@ func TestService(t *testing.T) { t.Parallel() t.Run("update script info eagerly and program lazily", func(t *testing.T) { t.Parallel() - service, _ := projecttestutil.Setup(files) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + service, _ := projecttestutil.Setup(defaultFiles) + 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() service.ChangeFile("/home/projects/TS/p1/src/x.ts", []ls.TextChange{{TextRange: core.NewTextRange(17, 18), NewText: "2"}}) @@ -89,8 +90,8 @@ func TestService(t *testing.T) { t.Run("unchanged source files are reused", func(t *testing.T) { t.Parallel() - service, _ := projecttestutil.Setup(files) - service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + service, _ := projecttestutil.Setup(defaultFiles) + 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") @@ -100,10 +101,10 @@ func TestService(t *testing.T) { t.Run("change can pull in new files", func(t *testing.T) { t.Parallel() - filesCopy := maps.Clone(files) - filesCopy["/home/projects/TS/p1/y.ts"] = `export const y = 2;` - service, _ := projecttestutil.Setup(filesCopy) - service.OpenFile("/home/projects/TS/p1/src/index.ts", filesCopy["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p1/y.ts"] = `export const y = 2;` + service, _ := projecttestutil.Setup(files) + service.OpenFile("/home/projects/TS/p1/src/index.ts", files["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") assert.Check(t, service.GetScriptInfo("/home/projects/TS/p1/y.ts") == nil) service.ChangeFile("/home/projects/TS/p1/src/index.ts", []ls.TextChange{{TextRange: core.NewTextRange(0, 0), NewText: `import { y } from "../y";\n`}}) @@ -117,23 +118,23 @@ func TestService(t *testing.T) { t.Parallel() t.Run("delete a file, close it, recreate it", func(t *testing.T) { t.Parallel() - service, host := projecttestutil.Setup(files) - 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, "") + service, host := projecttestutil.Setup(defaultFiles) + service.OpenFile("/home/projects/TS/p1/src/x.ts", defaultFiles["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p1/src/index.ts", defaultFiles["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") assert.Equal(t, service.SourceFileCount(), 2) - filesCopy := maps.Clone(files) - delete(filesCopy, "/home/projects/TS/p1/src/x.ts") - host.ReplaceFS(filesCopy) + files := maps.Clone(defaultFiles) + delete(files, "/home/projects/TS/p1/src/x.ts") + host.ReplaceFS(files) service.CloseFile("/home/projects/TS/p1/src/x.ts") assert.Check(t, service.GetScriptInfo("/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.SourceFileCount(), 1) - filesCopy["/home/projects/TS/p1/src/x.ts"] = `` - host.ReplaceFS(filesCopy) - service.OpenFile("/home/projects/TS/p1/src/x.ts", filesCopy["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + files["/home/projects/TS/p1/src/x.ts"] = `` + host.ReplaceFS(files) + service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") assert.Equal(t, service.GetScriptInfo("/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(), "") @@ -144,22 +145,22 @@ func TestService(t *testing.T) { t.Parallel() t.Run("delete a file, close it, recreate it", func(t *testing.T) { t.Parallel() - filesCopy := maps.Clone(files) - delete(filesCopy, "/home/projects/TS/p1/tsconfig.json") - service, host := projecttestutil.Setup(filesCopy) - 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, "") + files := maps.Clone(defaultFiles) + delete(files, "/home/projects/TS/p1/tsconfig.json") + service, host := projecttestutil.Setup(files) + service.OpenFile("/home/projects/TS/p1/src/x.ts", defaultFiles["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p1/src/index.ts", defaultFiles["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - delete(filesCopy, "/home/projects/TS/p1/src/x.ts") - host.ReplaceFS(filesCopy) + delete(files, "/home/projects/TS/p1/src/x.ts") + host.ReplaceFS(files) service.CloseFile("/home/projects/TS/p1/src/x.ts") assert.Check(t, service.GetScriptInfo("/home/projects/TS/p1/src/x.ts") == nil) assert.Check(t, service.Projects()[0].GetProgram().GetSourceFile("/home/projects/TS/p1/src/x.ts") == nil) - filesCopy["/home/projects/TS/p1/src/x.ts"] = `` - host.ReplaceFS(filesCopy) - service.OpenFile("/home/projects/TS/p1/src/x.ts", filesCopy["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + files["/home/projects/TS/p1/src/x.ts"] = `` + host.ReplaceFS(files) + service.OpenFile("/home/projects/TS/p1/src/x.ts", files["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") assert.Equal(t, service.GetScriptInfo("/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(), "") @@ -171,8 +172,8 @@ func TestService(t *testing.T) { t.Parallel() t.Run("projects with similar options share source files", func(t *testing.T) { t.Parallel() - filesCopy := maps.Clone(files) - filesCopy["/home/projects/TS/p2/tsconfig.json"] = `{ + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p2/tsconfig.json"] = `{ "compilerOptions": { "noLib": true, "module": "nodenext", @@ -180,10 +181,10 @@ func TestService(t *testing.T) { "noCheck": true // Added }, }` - filesCopy["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` - service, _ := projecttestutil.Setup(filesCopy) - service.OpenFile("/home/projects/TS/p1/src/index.ts", filesCopy["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", filesCopy["/home/projects/TS/p2/src/index.ts"], core.ScriptKindTS, "") + files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` + service, _ := projecttestutil.Setup(files) + 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) _, p1 := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") _, p2 := service.EnsureDefaultProjectForFile("/home/projects/TS/p2/src/index.ts") @@ -196,17 +197,17 @@ func TestService(t *testing.T) { t.Run("projects with different options do not share source files", func(t *testing.T) { t.Parallel() - filesCopy := maps.Clone(files) - filesCopy["/home/projects/TS/p2/tsconfig.json"] = `{ + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p2/tsconfig.json"] = `{ "compilerOptions": { "module": "nodenext", "jsx": "react" } }` - filesCopy["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` - service, _ := projecttestutil.Setup(filesCopy) - service.OpenFile("/home/projects/TS/p1/src/index.ts", filesCopy["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") - service.OpenFile("/home/projects/TS/p2/src/index.ts", filesCopy["/home/projects/TS/p2/src/index.ts"], core.ScriptKindTS, "") + files["/home/projects/TS/p2/src/index.ts"] = `import { x } from "../../p1/src/x";` + service, _ := projecttestutil.Setup(files) + 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) _, p1 := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") _, p2 := service.EnsureDefaultProjectForFile("/home/projects/TS/p2/src/index.ts") @@ -216,4 +217,226 @@ func TestService(t *testing.T) { 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() + service, host := projecttestutil.Setup(defaultFiles) + service.OpenFile("/home/projects/TS/p1/src/x.ts", defaultFiles["/home/projects/TS/p1/src/x.ts"], core.ScriptKindTS, "") + service.OpenFile("/home/projects/TS/p1/src/index.ts", defaultFiles["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") + _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") + programBefore := project.GetProgram() + + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p1/src/x.ts"] = `export const x = 2;` + host.ReplaceFS(files) + assert.NilError(t, service.OnWatchedFilesChanged([]*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() + service, host := projecttestutil.Setup(defaultFiles) + service.OpenFile("/home/projects/TS/p1/src/index.ts", defaultFiles["/home/projects/TS/p1/src/index.ts"], core.ScriptKindTS, "") + _, project := service.EnsureDefaultProjectForFile("/home/projects/TS/p1/src/index.ts") + programBefore := project.GetProgram() + + files := maps.Clone(defaultFiles) + files["/home/projects/TS/p1/src/x.ts"] = `export const x = 2;` + host.ReplaceFS(files) + assert.NilError(t, service.OnWatchedFilesChanged([]*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) + 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(program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + + filesCopy := maps.Clone(files) + filesCopy["/home/projects/TS/p1/tsconfig.json"] = `{ + "compilerOptions": { + "noLib": false, + "strict": true + } + }` + host.ReplaceFS(filesCopy) + assert.NilError(t, service.OnWatchedFilesChanged([]*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeChanged, + Uri: "file:///home/projects/TS/p1/tsconfig.json", + }, + })) + + program = project.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(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) + 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(program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + + filesCopy := maps.Clone(files) + delete(filesCopy, "/home/projects/TS/p1/src/x.ts") + host.ReplaceFS(filesCopy) + assert.NilError(t, service.OnWatchedFilesChanged([]*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeDeleted, + Uri: "file:///home/projects/TS/p1/src/x.ts", + }, + })) + + program = project.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(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) + 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(program.GetSourceFile("/home/projects/TS/p1/src/x.ts"))), 0) + + filesCopy := maps.Clone(files) + delete(filesCopy, "/home/projects/TS/p1/src/index.ts") + host.ReplaceFS(filesCopy) + assert.NilError(t, service.OnWatchedFilesChanged([]*lsproto.FileEvent{ + { + Type: lsproto.FileChangeTypeDeleted, + Uri: "file:///home/projects/TS/p1/src/index.ts", + }, + })) + + program = project.GetProgram() + assert.Equal(t, len(program.GetSemanticDiagnostics(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) + 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(program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + + // Add the missing file + filesCopy := maps.Clone(files) + filesCopy["/home/projects/TS/p1/src/y.ts"] = `export const y = 1;` + host.ReplaceFS(filesCopy) + assert.NilError(t, service.OnWatchedFilesChanged([]*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(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 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": `import { z } from "./z";`, + } + service, host := projecttestutil.Setup(files) + 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(program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 1) + + // Add a new file through wildcard inclusion + filesCopy := maps.Clone(files) + filesCopy["/home/projects/TS/p1/src/z.ts"] = `export const z = 1;` + host.ReplaceFS(filesCopy) + assert.NilError(t, service.OnWatchedFilesChanged([]*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(program.GetSourceFile("/home/projects/TS/p1/src/index.ts"))), 0) + assert.Check(t, program.GetSourceFile("/home/projects/TS/p1/src/z.ts") != nil) + }) + }) } diff --git a/internal/project/watch.go b/internal/project/watch.go new file mode 100644 index 0000000000..18db3a8c91 --- /dev/null +++ b/internal/project/watch.go @@ -0,0 +1,61 @@ +package project + +import ( + "slices" + + "github.com/microsoft/typescript-go/internal/lsp/lsproto" +) + +const ( + fileGlobPattern = "*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}" + recursiveFileGlobPattern = "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}" +) + +type watchedFiles[T any] struct { + client Client + getGlobs func(data T) []string + watchKind lsproto.WatchKind + + data T + globs []string + watcherID WatcherHandle +} + +func newWatchedFiles[T any](client Client, watchKind lsproto.WatchKind, getGlobs func(data T) []string) *watchedFiles[T] { + return &watchedFiles[T]{ + client: client, + watchKind: watchKind, + getGlobs: getGlobs, + } +} + +func (w *watchedFiles[T]) update(newData T) (updated bool, err error) { + newGlobs := w.getGlobs(newData) + w.data = newData + if slices.Equal(w.globs, newGlobs) { + return false, nil + } + + w.globs = newGlobs + if w.watcherID != "" { + if err = w.client.UnwatchFiles(w.watcherID); err != nil { + return false, err + } + } + + 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.client.WatchFiles(watchers) + if err != nil { + return false, err + } + w.watcherID = watcherID + return true, nil +} diff --git a/internal/testutil/projecttestutil/projecttestutil.go b/internal/testutil/projecttestutil/projecttestutil.go index c2e324ee4d..d7898ea82a 100644 --- a/internal/testutil/projecttestutil/projecttestutil.go +++ b/internal/testutil/projecttestutil/projecttestutil.go @@ -47,6 +47,11 @@ func (p *ProjectServiceHost) NewLine() string { return "\n" } +// Client implements project.ProjectServiceHost. +func (p *ProjectServiceHost) Client() project.Client { + return nil +} + func (p *ProjectServiceHost) ReplaceFS(files map[string]string) { p.fs = bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/)) } diff --git a/internal/tsoptions/commandlineparser.go b/internal/tsoptions/commandlineparser.go index a9916cfe4b..4d70a00981 100644 --- a/internal/tsoptions/commandlineparser.go +++ b/internal/tsoptions/commandlineparser.go @@ -9,6 +9,7 @@ import ( "github.com/microsoft/typescript-go/internal/core" "github.com/microsoft/typescript-go/internal/diagnostics" "github.com/microsoft/typescript-go/internal/stringutil" + "github.com/microsoft/typescript-go/internal/tspath" "github.com/microsoft/typescript-go/internal/vfs" ) @@ -58,6 +59,11 @@ func ParseCommandLine( 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(), + }, } } diff --git a/internal/tsoptions/parsedcommandline.go b/internal/tsoptions/parsedcommandline.go index 8ddec3c0ef..66d708f977 100644 --- a/internal/tsoptions/parsedcommandline.go +++ b/internal/tsoptions/parsedcommandline.go @@ -2,20 +2,38 @@ package tsoptions import ( "slices" + "sync" "github.com/microsoft/typescript-go/internal/ast" "github.com/microsoft/typescript-go/internal/core" + "github.com/microsoft/typescript-go/internal/tspath" ) type ParsedCommandLine struct { ParsedConfig *core.ParsedOptions `json:"parsedConfig"` - ConfigFile *TsConfigSourceFile `json:"configFile"` // TsConfigSourceFile, used in Program and ExecuteCommandLine - Errors []*ast.Diagnostic `json:"errors"` - Raw any `json:"raw"` - // WildcardDirectories map[string]watchDirectoryFlags - CompileOnSave *bool `json:"compileOnSave"` + ConfigFile *TsConfigSourceFile `json:"configFile"` // TsConfigSourceFile, used in Program and ExecuteCommandLine + Errors []*ast.Diagnostic `json:"errors"` + Raw any `json:"raw"` + CompileOnSave *bool `json:"compileOnSave"` // TypeAquisition *core.TypeAcquisition + + comparePathsOptions tspath.ComparePathsOptions + wildcardDirectoriesOnce sync.Once + wildcardDirectories map[string]bool +} + +// WildcardDirectories returns the cached wildcard directories, initializing them if needed +func (p *ParsedCommandLine) WildcardDirectories() map[string]bool { + p.wildcardDirectoriesOnce.Do(func() { + p.wildcardDirectories = getWildcardDirectories( + p.ConfigFile.configFileSpecs.validatedIncludeSpecs, + p.ConfigFile.configFileSpecs.validatedExcludeSpecs, + p.comparePathsOptions, + ) + }) + + return p.wildcardDirectories } func (p *ParsedCommandLine) SetParsedOptions(o *core.ParsedOptions) { @@ -45,3 +63,32 @@ func (p *ParsedCommandLine) GetConfigFileParsingDiagnostics() []*ast.Diagnostic } return p.Errors } + +func (p *ParsedCommandLine) MatchesFileName(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool { + path := tspath.ToPath(fileName, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) + if slices.ContainsFunc(p.FileNames(), func(f string) bool { + return path == tspath.ToPath(f, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) + }) { + return true + } + + if p.ConfigFile == nil { + return false + } + + if slices.ContainsFunc(p.ConfigFile.configFileSpecs.validatedFilesSpec, func(f string) bool { + return path == tspath.ToPath(f, comparePathsOptions.CurrentDirectory, comparePathsOptions.UseCaseSensitiveFileNames) + }) { + return true + } + + if len(p.ConfigFile.configFileSpecs.validatedIncludeSpecs) == 0 { + return false + } + + if p.ConfigFile.configFileSpecs.matchesExclude(fileName, comparePathsOptions) { + return false + } + + return p.ConfigFile.configFileSpecs.matchesInclude(fileName, comparePathsOptions) +} diff --git a/internal/tsoptions/tsconfigparsing.go b/internal/tsoptions/tsconfigparsing.go index 1430c2a45e..826a3b86e1 100644 --- a/internal/tsoptions/tsconfigparsing.go +++ b/internal/tsoptions/tsconfigparsing.go @@ -95,15 +95,51 @@ type configFileSpecs struct { validatedExcludeSpecs []string isDefaultIncludeSpec bool } + +func (c *configFileSpecs) matchesExclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool { + if len(c.validatedExcludeSpecs) == 0 { + return false + } + excludePattern := getRegularExpressionForWildcard(c.validatedExcludeSpecs, comparePathsOptions.CurrentDirectory, "exclude") + excludeRegex := getRegexFromPattern(excludePattern, comparePathsOptions.UseCaseSensitiveFileNames) + if match, err := excludeRegex.MatchString(fileName); err == nil && match { + return true + } + if !tspath.HasExtension(fileName) { + if match, err := excludeRegex.MatchString(tspath.EnsureTrailingDirectorySeparator(fileName)); err == nil && match { + return true + } + } + return false +} + +func (c *configFileSpecs) matchesInclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool { + if len(c.validatedIncludeSpecs) == 0 { + return false + } + for _, spec := range c.validatedIncludeSpecs { + includePattern := getPatternFromSpec(spec, comparePathsOptions.CurrentDirectory, "files") + if includePattern != "" { + includeRegex := getRegexFromPattern(includePattern, comparePathsOptions.UseCaseSensitiveFileNames) + if match, err := includeRegex.MatchString(fileName); err == nil && match { + return true + } + } + } + return false +} + type fileExtensionInfo struct { extension string isMixedContent bool scriptKind core.ScriptKind } + type ExtendedConfigCacheEntry struct { extendedResult *TsConfigSourceFile extendedConfig *parsedTsconfig } + type parsedTsconfig struct { raw any options *core.CompilerOptions @@ -1183,6 +1219,11 @@ func parseJsonConfigFileContentWorker( ConfigFile: sourceFile, Raw: parsedConfig.raw, Errors: errors, + + comparePathsOptions: tspath.ComparePathsOptions{ + UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(), + CurrentDirectory: basePathForFileNames, + }, } } diff --git a/internal/tsoptions/utilities.go b/internal/tsoptions/utilities.go index b5889636ff..8810b6bbd0 100644 --- a/internal/tsoptions/utilities.go +++ b/internal/tsoptions/utilities.go @@ -150,6 +150,19 @@ var wildcardMatchers = map[usage]WildcardMatcher{ usageExclude: excludeMatcher, } +func getPatternFromSpec( + spec string, + basePath string, + usage usage, +) string { + pattern := getSubPatternFromSpec(spec, basePath, usage, wildcardMatchers[usage]) + if pattern == "" { + return "" + } + ending := core.IfElse(usage == "exclude", "($|/)", "$") + return fmt.Sprintf("^(%s)%s", pattern, ending) +} + func getSubPatternFromSpec( spec string, basePath string, diff --git a/internal/tsoptions/wildcarddirectories.go b/internal/tsoptions/wildcarddirectories.go new file mode 100644 index 0000000000..c906d14bf2 --- /dev/null +++ b/internal/tsoptions/wildcarddirectories.go @@ -0,0 +1,143 @@ +package tsoptions + +import ( + "regexp" + "strings" + + "github.com/dlclark/regexp2" + "github.com/microsoft/typescript-go/internal/tspath" +) + +func getWildcardDirectories(include []string, exclude []string, comparePathsOptions tspath.ComparePathsOptions) map[string]bool { + // We watch a directory recursively if it contains a wildcard anywhere in a directory segment + // of the pattern: + // + // /a/b/**/d - Watch /a/b recursively to catch changes to any d in any subfolder recursively + // /a/b/*/d - Watch /a/b recursively to catch any d in any immediate subfolder, even if a new subfolder is added + // /a/b - Watch /a/b recursively to catch changes to anything in any recursive subfoler + // + // We watch a directory without recursion if it contains a wildcard in the file segment of + // the pattern: + // + // /a/b/* - Watch /a/b directly to catch any new file + // /a/b/a?z - Watch /a/b directly to catch any new file matching a?z + + if len(include) == 0 { + return nil + } + + rawExcludeRegex := getRegularExpressionForWildcard(exclude, comparePathsOptions.CurrentDirectory, "exclude") + var excludeRegex *regexp.Regexp + if rawExcludeRegex != "" { + options := "" + if !comparePathsOptions.UseCaseSensitiveFileNames { + options = "(?i)" + } + excludeRegex = regexp.MustCompile(options + rawExcludeRegex) + } + + wildcardDirectories := make(map[string]bool) + wildCardKeyToPath := make(map[string]string) + + var recursiveKeys []string + + for _, file := range include { + spec := tspath.NormalizeSlashes(tspath.CombinePaths(comparePathsOptions.CurrentDirectory, file)) + if excludeRegex != nil && excludeRegex.MatchString(spec) { + continue + } + + match := getWildcardDirectoryFromSpec(spec, comparePathsOptions.UseCaseSensitiveFileNames) + if match != nil { + key := match.Key + path := match.Path + recursive := match.Recursive + + existingPath, existsPath := wildCardKeyToPath[key] + var existingRecursive bool + + if existsPath { + existingRecursive = wildcardDirectories[existingPath] + } + + if !existsPath || (!existingRecursive && recursive) { + pathToUse := path + if existsPath { + pathToUse = existingPath + } + wildcardDirectories[pathToUse] = recursive + + if !existsPath { + wildCardKeyToPath[key] = path + } + + if recursive { + recursiveKeys = append(recursiveKeys, key) + } + } + } + + // Remove any subpaths under an existing recursively watched directory + for path := range wildcardDirectories { + for _, recursiveKey := range recursiveKeys { + key := toCanonicalKey(path, comparePathsOptions.UseCaseSensitiveFileNames) + if key != recursiveKey && tspath.ContainsPath(recursiveKey, key, comparePathsOptions) { + delete(wildcardDirectories, path) + } + } + } + } + + return wildcardDirectories +} + +func toCanonicalKey(path string, useCaseSensitiveFileNames bool) string { + if useCaseSensitiveFileNames { + return path + } + return strings.ToLower(path) +} + +// wildcardDirectoryPattern matches paths with wildcard characters +var wildcardDirectoryPattern = regexp2.MustCompile(`^[^*?]*(?=\/[^/]*[*?])`, 0) + +// wildcardDirectoryMatch represents the result of a wildcard directory match +type wildcardDirectoryMatch struct { + Key string + Path string + Recursive bool +} + +func getWildcardDirectoryFromSpec(spec string, useCaseSensitiveFileNames bool) *wildcardDirectoryMatch { + match, _ := wildcardDirectoryPattern.FindStringMatch(spec) + if match != nil { + // We check this with a few `Index` calls because it's more efficient than complex regex + questionWildcardIndex := strings.Index(spec, "?") + starWildcardIndex := strings.Index(spec, "*") + lastDirectorySeparatorIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator) + + // Determine if this should be watched recursively + recursive := (questionWildcardIndex != -1 && questionWildcardIndex < lastDirectorySeparatorIndex) || + (starWildcardIndex != -1 && starWildcardIndex < lastDirectorySeparatorIndex) + + return &wildcardDirectoryMatch{ + Key: toCanonicalKey(match.String(), useCaseSensitiveFileNames), + Path: match.String(), + Recursive: recursive, + } + } + + if lastSepIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator); lastSepIndex != -1 { + lastSegment := spec[lastSepIndex+1:] + if isImplicitGlob(lastSegment) { + path := tspath.RemoveTrailingDirectorySeparator(spec) + return &wildcardDirectoryMatch{ + Key: toCanonicalKey(path, useCaseSensitiveFileNames), + Path: path, + Recursive: true, + } + } + } + + return nil +}