Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 866466e

Browse files
andrewbranchjakebailey
andauthoredMay 14, 2025··
Watch files over LSP (#806)
Co-authored-by: Jake Bailey <[email protected]>
1 parent 163e3c4 commit 866466e

23 files changed

+1735
-105
lines changed
 

‎internal/api/api.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,16 @@ func (api *API) PositionEncoding() lsproto.PositionEncodingKind {
119119
return lsproto.PositionEncodingKindUTF8
120120
}
121121

122+
// Client implements ProjectHost.
123+
func (api *API) Client() project.Client {
124+
return nil
125+
}
126+
127+
// IsWatchEnabled implements ProjectHost.
128+
func (api *API) IsWatchEnabled() bool {
129+
return false
130+
}
131+
122132
func (api *API) HandleRequest(id int, method string, payload []byte) ([]byte, error) {
123133
params, err := unmarshalPayload(method, payload)
124134
if err != nil {
@@ -351,7 +361,7 @@ func (api *API) getOrCreateScriptInfo(fileName string, path tspath.Path, scriptK
351361
if !ok {
352362
return nil
353363
}
354-
info = project.NewScriptInfo(fileName, path, scriptKind)
364+
info = project.NewScriptInfo(fileName, path, scriptKind, api.host.FS())
355365
info.SetTextFromDisk(content)
356366
api.scriptInfosMu.Lock()
357367
defer api.scriptInfosMu.Unlock()

‎internal/compiler/fileloader.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type fileLoader struct {
3838

3939
type processedFiles struct {
4040
files []*ast.SourceFile
41+
missingFiles []string
4142
resolvedModules map[tspath.Path]module.ModeAwareCache[*module.ResolvedModule]
4243
sourceFileMetaDatas map[tspath.Path]*ast.SourceFileMetaData
4344
jsxRuntimeImportSpecifiers map[tspath.Path]*jsxRuntimeImportSpecifier
@@ -85,6 +86,7 @@ func processAllProgramFiles(
8586
totalFileCount := int(loader.totalFileCount.Load())
8687
libFileCount := int(loader.libFileCount.Load())
8788

89+
var missingFiles []string
8890
files := make([]*ast.SourceFile, 0, totalFileCount-libFileCount)
8991
libFiles := make([]*ast.SourceFile, 0, totalFileCount) // totalFileCount here since we append files to it later to construct the final list
9092

@@ -95,6 +97,10 @@ func processAllProgramFiles(
9597

9698
for task := range loader.collectTasks(loader.rootTasks) {
9799
file := task.file
100+
if file == nil {
101+
missingFiles = append(missingFiles, task.normalizedFilePath)
102+
continue
103+
}
98104
if task.isLib {
99105
libFiles = append(libFiles, file)
100106
} else {
@@ -190,10 +196,8 @@ func (p *fileLoader) collectTasksWorker(tasks []*parseTask, seen core.Set[*parse
190196
}
191197
}
192198

193-
if task.file != nil {
194-
if !yield(task) {
195-
return false
196-
}
199+
if !yield(task) {
200+
return false
197201
}
198202
}
199203
return true
@@ -245,6 +249,10 @@ func (t *parseTask) start(loader *fileLoader) {
245249

246250
loader.wg.Queue(func() {
247251
file := loader.parseSourceFile(t.normalizedFilePath)
252+
if file == nil {
253+
return
254+
}
255+
248256
t.file = file
249257
loader.wg.Queue(func() {
250258
t.metadata = loader.loadSourceFileMetaData(file.Path())

‎internal/lsp/lsproto/jsonrpc.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type ID struct {
2828
int int32
2929
}
3030

31+
func NewIDString(str string) *ID {
32+
return &ID{str: str}
33+
}
34+
3135
func (id *ID) MarshalJSON() ([]byte, error) {
3236
if id.str != "" {
3337
return json.Marshal(id.str)
@@ -43,6 +47,13 @@ func (id *ID) UnmarshalJSON(data []byte) error {
4347
return json.Unmarshal(data, &id.int)
4448
}
4549

50+
func (id *ID) TryInt() (int32, bool) {
51+
if id == nil || id.str != "" {
52+
return 0, false
53+
}
54+
return id.int, true
55+
}
56+
4657
func (id *ID) MustInt() int32 {
4758
if id.str != "" {
4859
panic("ID is not an integer")
@@ -54,11 +65,19 @@ func (id *ID) MustInt() int32 {
5465

5566
type RequestMessage struct {
5667
JSONRPC JSONRPCVersion `json:"jsonrpc"`
57-
ID *ID `json:"id"`
68+
ID *ID `json:"id,omitempty"`
5869
Method Method `json:"method"`
5970
Params any `json:"params"`
6071
}
6172

73+
func NewRequestMessage(method Method, id *ID, params any) *RequestMessage {
74+
return &RequestMessage{
75+
ID: id,
76+
Method: method,
77+
Params: params,
78+
}
79+
}
80+
6281
func (r *RequestMessage) UnmarshalJSON(data []byte) error {
6382
var raw struct {
6483
JSONRPC JSONRPCVersion `json:"jsonrpc"`

‎internal/lsp/server.go

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,18 @@ func NewServer(opts *ServerOptions) *Server {
4343
}
4444
}
4545

46-
var _ project.ServiceHost = (*Server)(nil)
46+
var (
47+
_ project.ServiceHost = (*Server)(nil)
48+
_ project.Client = (*Server)(nil)
49+
)
4750

4851
type Server struct {
4952
r *lsproto.BaseReader
5053
w *lsproto.BaseWriter
5154

5255
stderr io.Writer
5356

57+
clientSeq int32
5458
requestMethod string
5559
requestTime time.Time
5660

@@ -62,36 +66,100 @@ type Server struct {
6266
initializeParams *lsproto.InitializeParams
6367
positionEncoding lsproto.PositionEncodingKind
6468

69+
watchEnabled bool
70+
watcherID int
71+
watchers core.Set[project.WatcherHandle]
6572
logger *project.Logger
6673
projectService *project.Service
6774
converters *ls.Converters
6875
}
6976

70-
// FS implements project.ProjectServiceHost.
77+
// FS implements project.ServiceHost.
7178
func (s *Server) FS() vfs.FS {
7279
return s.fs
7380
}
7481

75-
// DefaultLibraryPath implements project.ProjectServiceHost.
82+
// DefaultLibraryPath implements project.ServiceHost.
7683
func (s *Server) DefaultLibraryPath() string {
7784
return s.defaultLibraryPath
7885
}
7986

80-
// GetCurrentDirectory implements project.ProjectServiceHost.
87+
// GetCurrentDirectory implements project.ServiceHost.
8188
func (s *Server) GetCurrentDirectory() string {
8289
return s.cwd
8390
}
8491

85-
// NewLine implements project.ProjectServiceHost.
92+
// NewLine implements project.ServiceHost.
8693
func (s *Server) NewLine() string {
8794
return s.newLine.GetNewLineCharacter()
8895
}
8996

90-
// Trace implements project.ProjectServiceHost.
97+
// Trace implements project.ServiceHost.
9198
func (s *Server) Trace(msg string) {
9299
s.Log(msg)
93100
}
94101

102+
// Client implements project.ServiceHost.
103+
func (s *Server) Client() project.Client {
104+
if !s.watchEnabled {
105+
return nil
106+
}
107+
return s
108+
}
109+
110+
// WatchFiles implements project.Client.
111+
func (s *Server) WatchFiles(watchers []*lsproto.FileSystemWatcher) (project.WatcherHandle, error) {
112+
watcherId := fmt.Sprintf("watcher-%d", s.watcherID)
113+
if err := s.sendRequest(lsproto.MethodClientRegisterCapability, &lsproto.RegistrationParams{
114+
Registrations: []*lsproto.Registration{
115+
{
116+
Id: watcherId,
117+
Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles),
118+
RegisterOptions: ptrTo(any(lsproto.DidChangeWatchedFilesRegistrationOptions{
119+
Watchers: watchers,
120+
})),
121+
},
122+
},
123+
}); err != nil {
124+
return "", fmt.Errorf("failed to register file watcher: %w", err)
125+
}
126+
127+
handle := project.WatcherHandle(watcherId)
128+
s.watchers.Add(handle)
129+
s.watcherID++
130+
return handle, nil
131+
}
132+
133+
// UnwatchFiles implements project.Client.
134+
func (s *Server) UnwatchFiles(handle project.WatcherHandle) error {
135+
if s.watchers.Has(handle) {
136+
if err := s.sendRequest(lsproto.MethodClientUnregisterCapability, &lsproto.UnregistrationParams{
137+
Unregisterations: []*lsproto.Unregistration{
138+
{
139+
Id: string(handle),
140+
Method: string(lsproto.MethodWorkspaceDidChangeWatchedFiles),
141+
},
142+
},
143+
}); err != nil {
144+
return fmt.Errorf("failed to unregister file watcher: %w", err)
145+
}
146+
s.watchers.Delete(handle)
147+
return nil
148+
}
149+
150+
return fmt.Errorf("no file watcher exists with ID %s", handle)
151+
}
152+
153+
// RefreshDiagnostics implements project.Client.
154+
func (s *Server) RefreshDiagnostics() error {
155+
if ptrIsTrue(s.initializeParams.Capabilities.Workspace.Diagnostics.RefreshSupport) {
156+
if err := s.sendRequest(lsproto.MethodWorkspaceDiagnosticRefresh, nil); err != nil {
157+
return fmt.Errorf("failed to refresh diagnostics: %w", err)
158+
}
159+
}
160+
return nil
161+
}
162+
95163
func (s *Server) Run() error {
96164
for {
97165
req, err := s.read()
@@ -105,6 +173,11 @@ func (s *Server) Run() error {
105173
return err
106174
}
107175

176+
// TODO: handle response messages
177+
if req == nil {
178+
continue
179+
}
180+
108181
if s.initializeParams == nil {
109182
if req.Method == lsproto.MethodInitialize {
110183
if err := s.handleInitialize(req); err != nil {
@@ -132,12 +205,37 @@ func (s *Server) read() (*lsproto.RequestMessage, error) {
132205

133206
req := &lsproto.RequestMessage{}
134207
if err := json.Unmarshal(data, req); err != nil {
208+
res := &lsproto.ResponseMessage{}
209+
if err = json.Unmarshal(data, res); err == nil {
210+
// !!! TODO: handle response
211+
return nil, nil
212+
}
135213
return nil, fmt.Errorf("%w: %w", lsproto.ErrInvalidRequest, err)
136214
}
137215

138216
return req, nil
139217
}
140218

219+
func (s *Server) sendRequest(method lsproto.Method, params any) error {
220+
s.clientSeq++
221+
id := lsproto.NewIDString(fmt.Sprintf("ts%d", s.clientSeq))
222+
req := lsproto.NewRequestMessage(method, id, params)
223+
data, err := json.Marshal(req)
224+
if err != nil {
225+
return err
226+
}
227+
return s.w.Write(data)
228+
}
229+
230+
func (s *Server) sendNotification(method lsproto.Method, params any) error {
231+
req := lsproto.NewRequestMessage(method, nil /*id*/, params)
232+
data, err := json.Marshal(req)
233+
if err != nil {
234+
return err
235+
}
236+
return s.w.Write(data)
237+
}
238+
141239
func (s *Server) sendResult(id *lsproto.ID, result any) error {
142240
return s.sendResponse(&lsproto.ResponseMessage{
143241
ID: id,
@@ -189,6 +287,8 @@ func (s *Server) handleMessage(req *lsproto.RequestMessage) error {
189287
return s.handleDidSave(req)
190288
case *lsproto.DidCloseTextDocumentParams:
191289
return s.handleDidClose(req)
290+
case *lsproto.DidChangeWatchedFilesParams:
291+
return s.handleDidChangeWatchedFiles(req)
192292
case *lsproto.DocumentDiagnosticParams:
193293
return s.handleDocumentDiagnostic(req)
194294
case *lsproto.HoverParams:
@@ -262,9 +362,14 @@ func (s *Server) handleInitialize(req *lsproto.RequestMessage) error {
262362
}
263363

264364
func (s *Server) handleInitialized(req *lsproto.RequestMessage) error {
365+
if s.initializeParams.Capabilities.Workspace.DidChangeWatchedFiles != nil && *s.initializeParams.Capabilities.Workspace.DidChangeWatchedFiles.DynamicRegistration {
366+
s.watchEnabled = true
367+
}
368+
265369
s.logger = project.NewLogger([]io.Writer{s.stderr}, "" /*file*/, project.LogLevelVerbose)
266370
s.projectService = project.NewService(s, project.ServiceOptions{
267371
Logger: s.logger,
372+
WatchEnabled: s.watchEnabled,
268373
PositionEncoding: s.positionEncoding,
269374
})
270375

@@ -322,6 +427,11 @@ func (s *Server) handleDidClose(req *lsproto.RequestMessage) error {
322427
return nil
323428
}
324429

430+
func (s *Server) handleDidChangeWatchedFiles(req *lsproto.RequestMessage) error {
431+
params := req.Params.(*lsproto.DidChangeWatchedFilesParams)
432+
return s.projectService.OnWatchedFilesChanged(params.Changes)
433+
}
434+
325435
func (s *Server) handleDocumentDiagnostic(req *lsproto.RequestMessage) error {
326436
params := req.Params.(*lsproto.DocumentDiagnosticParams)
327437
file, project := s.getFileAndProject(params.TextDocument.Uri)
@@ -445,3 +555,10 @@ func codeFence(lang string, code string) string {
445555
func ptrTo[T any](v T) *T {
446556
return &v
447557
}
558+
559+
func ptrIsTrue(v *bool) bool {
560+
if v == nil {
561+
return false
562+
}
563+
return *v
564+
}

‎internal/project/documentregistry.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ func (r *DocumentRegistry) getDocumentWorker(
9292
if entry, ok := r.documents.Load(key); ok {
9393
// We have an entry for this file. However, it may be for a different version of
9494
// the script snapshot. If so, update it appropriately.
95-
if entry.sourceFile.Version != scriptInfo.version {
95+
if entry.sourceFile.Version != scriptInfo.Version() {
9696
sourceFile := parser.ParseSourceFile(scriptInfo.fileName, scriptInfo.path, scriptInfo.text, scriptTarget, scanner.JSDocParsingModeParseAll)
97-
sourceFile.Version = scriptInfo.version
97+
sourceFile.Version = scriptInfo.Version()
9898
entry.mu.Lock()
9999
defer entry.mu.Unlock()
100100
entry.sourceFile = sourceFile
@@ -104,7 +104,7 @@ func (r *DocumentRegistry) getDocumentWorker(
104104
} else {
105105
// Have never seen this file with these settings. Create a new source file for it.
106106
sourceFile := parser.ParseSourceFile(scriptInfo.fileName, scriptInfo.path, scriptInfo.text, scriptTarget, scanner.JSDocParsingModeParseAll)
107-
sourceFile.Version = scriptInfo.version
107+
sourceFile.Version = scriptInfo.Version()
108108
entry, _ := r.documents.LoadOrStore(key, &registryEntry{
109109
sourceFile: sourceFile,
110110
refCount: 0,

‎internal/project/host.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
package project
22

3-
import "github.com/microsoft/typescript-go/internal/vfs"
3+
import (
4+
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
5+
"github.com/microsoft/typescript-go/internal/vfs"
6+
)
7+
8+
type WatcherHandle string
9+
10+
type Client interface {
11+
WatchFiles(watchers []*lsproto.FileSystemWatcher) (WatcherHandle, error)
12+
UnwatchFiles(handle WatcherHandle) error
13+
RefreshDiagnostics() error
14+
}
415

516
type ServiceHost interface {
617
FS() vfs.FS
718
DefaultLibraryPath() string
819
GetCurrentDirectory() string
920
NewLine() string
21+
22+
Client() Client
1023
}

‎internal/project/project.go

Lines changed: 178 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package project
22

33
import (
44
"fmt"
5+
"maps"
6+
"slices"
57
"strings"
68
"sync"
79

@@ -17,6 +19,7 @@ import (
1719
)
1820

1921
//go:generate go tool golang.org/x/tools/cmd/stringer -type=Kind -output=project_stringer_generated.go
22+
const hr = "-----------------------------------------------"
2023

2124
var projectNamer = &namer{}
2225

@@ -31,6 +34,14 @@ const (
3134
KindAuxiliary
3235
)
3336

37+
type PendingReload int
38+
39+
const (
40+
PendingReloadNone PendingReload = iota
41+
PendingReloadFileNames
42+
PendingReloadFull
43+
)
44+
3445
type ProjectHost interface {
3546
tsoptions.ParseConfigHost
3647
NewLine() string
@@ -41,6 +52,9 @@ type ProjectHost interface {
4152
OnDiscoveredSymlink(info *ScriptInfo)
4253
Log(s string)
4354
PositionEncoding() lsproto.PositionEncodingKind
55+
56+
IsWatchEnabled() bool
57+
Client() Client
4458
}
4559

4660
type Project struct {
@@ -56,27 +70,38 @@ type Project struct {
5670
hasAddedOrRemovedFiles bool
5771
hasAddedOrRemovedSymlinks bool
5872
deferredClose bool
59-
reloadConfig bool
73+
pendingReload PendingReload
6074

61-
currentDirectory string
75+
comparePathsOptions tspath.ComparePathsOptions
76+
currentDirectory string
6277
// Inferred projects only
6378
rootPath tspath.Path
6479

6580
configFileName string
6681
configFilePath tspath.Path
6782
// rootFileNames was a map from Path to { NormalizedPath, ScriptInfo? } in the original code.
6883
// But the ProjectService owns script infos, so it's not clear why there was an extra pointer.
69-
rootFileNames *collections.OrderedMap[tspath.Path, string]
70-
compilerOptions *core.CompilerOptions
71-
languageService *ls.LanguageService
72-
program *compiler.Program
84+
rootFileNames *collections.OrderedMap[tspath.Path, string]
85+
compilerOptions *core.CompilerOptions
86+
parsedCommandLine *tsoptions.ParsedCommandLine
87+
languageService *ls.LanguageService
88+
program *compiler.Program
89+
90+
// Watchers
91+
rootFilesWatch *watchedFiles[[]string]
92+
failedLookupsWatch *watchedFiles[map[tspath.Path]string]
93+
affectingLocationsWatch *watchedFiles[map[tspath.Path]string]
7394
}
7495

7596
func NewConfiguredProject(configFileName string, configFilePath tspath.Path, host ProjectHost) *Project {
7697
project := NewProject(configFileName, KindConfigured, tspath.GetDirectoryPath(configFileName), host)
7798
project.configFileName = configFileName
7899
project.configFilePath = configFilePath
79100
project.initialLoadPending = true
101+
client := host.Client()
102+
if host.IsWatchEnabled() && client != nil {
103+
project.rootFilesWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, core.Identity)
104+
}
80105
return project
81106
}
82107

@@ -96,6 +121,19 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos
96121
currentDirectory: currentDirectory,
97122
rootFileNames: &collections.OrderedMap[tspath.Path, string]{},
98123
}
124+
project.comparePathsOptions = tspath.ComparePathsOptions{
125+
CurrentDirectory: currentDirectory,
126+
UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(),
127+
}
128+
client := host.Client()
129+
if host.IsWatchEnabled() && client != nil {
130+
project.failedLookupsWatch = newWatchedFiles(client, lsproto.WatchKindCreate, func(data map[tspath.Path]string) []string {
131+
return slices.Sorted(maps.Values(data))
132+
})
133+
project.affectingLocationsWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, func(data map[tspath.Path]string) []string {
134+
return slices.Sorted(maps.Values(data))
135+
})
136+
}
99137
project.languageService = ls.NewLanguageService(project)
100138
project.markAsDirty()
101139
return project
@@ -128,13 +166,7 @@ func (p *Project) GetProjectVersion() int {
128166

129167
// GetRootFileNames implements LanguageServiceHost.
130168
func (p *Project) GetRootFileNames() []string {
131-
fileNames := make([]string, 0, p.rootFileNames.Size())
132-
for path, fileName := range p.rootFileNames.Entries() {
133-
if p.host.GetScriptInfoByPath(path) != nil {
134-
fileNames = append(fileNames, fileName)
135-
}
136-
}
137-
return fileNames
169+
return slices.Collect(p.rootFileNames.Values())
138170
}
139171

140172
// GetSourceFile implements LanguageServiceHost.
@@ -205,6 +237,98 @@ func (p *Project) LanguageService() *ls.LanguageService {
205237
return p.languageService
206238
}
207239

240+
func (p *Project) getRootFileWatchGlobs() []string {
241+
if p.kind == KindConfigured {
242+
globs := p.parsedCommandLine.WildcardDirectories()
243+
result := make([]string, 0, len(globs)+1)
244+
result = append(result, p.configFileName)
245+
for dir, recursive := range globs {
246+
result = append(result, fmt.Sprintf("%s/%s", dir, core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern)))
247+
}
248+
for _, fileName := range p.parsedCommandLine.LiteralFileNames() {
249+
result = append(result, fileName)
250+
}
251+
return result
252+
}
253+
return nil
254+
}
255+
256+
func (p *Project) getModuleResolutionWatchGlobs() (failedLookups map[tspath.Path]string, affectingLocaions map[tspath.Path]string) {
257+
failedLookups = make(map[tspath.Path]string)
258+
affectingLocaions = make(map[tspath.Path]string)
259+
for _, resolvedModulesInFile := range p.program.GetResolvedModules() {
260+
for _, resolvedModule := range resolvedModulesInFile {
261+
for _, failedLookupLocation := range resolvedModule.FailedLookupLocations {
262+
path := p.toPath(failedLookupLocation)
263+
if _, ok := failedLookups[path]; !ok {
264+
failedLookups[path] = failedLookupLocation
265+
}
266+
}
267+
for _, affectingLocation := range resolvedModule.AffectingLocations {
268+
path := p.toPath(affectingLocation)
269+
if _, ok := affectingLocaions[path]; !ok {
270+
affectingLocaions[path] = affectingLocation
271+
}
272+
}
273+
}
274+
}
275+
return failedLookups, affectingLocaions
276+
}
277+
278+
func (p *Project) updateWatchers() {
279+
client := p.host.Client()
280+
if !p.host.IsWatchEnabled() || client == nil {
281+
return
282+
}
283+
284+
rootFileGlobs := p.getRootFileWatchGlobs()
285+
failedLookupGlobs, affectingLocationGlobs := p.getModuleResolutionWatchGlobs()
286+
287+
if rootFileGlobs != nil {
288+
if updated, err := p.rootFilesWatch.update(rootFileGlobs); err != nil {
289+
p.log(fmt.Sprintf("Failed to update root file watch: %v", err))
290+
} else if updated {
291+
p.log("Root file watches updated:\n" + formatFileList(rootFileGlobs, "\t", hr))
292+
}
293+
}
294+
295+
if updated, err := p.failedLookupsWatch.update(failedLookupGlobs); err != nil {
296+
p.log(fmt.Sprintf("Failed to update failed lookup watch: %v", err))
297+
} else if updated {
298+
p.log("Failed lookup watches updated:\n" + formatFileList(p.failedLookupsWatch.globs, "\t", hr))
299+
}
300+
301+
if updated, err := p.affectingLocationsWatch.update(affectingLocationGlobs); err != nil {
302+
p.log(fmt.Sprintf("Failed to update affecting location watch: %v", err))
303+
} else if updated {
304+
p.log("Affecting location watches updated:\n" + formatFileList(p.affectingLocationsWatch.globs, "\t", hr))
305+
}
306+
}
307+
308+
// onWatchEventForNilScriptInfo is fired for watch events that are not the
309+
// project tsconfig, and do not have a ScriptInfo for the associated file.
310+
// This could be a case of one of the following:
311+
// - A file is being created that will be added to the project.
312+
// - An affecting location was changed.
313+
// - A file is being created that matches a watch glob, but is not actually
314+
// part of the project, e.g., a .js file in a project without --allowJs.
315+
func (p *Project) onWatchEventForNilScriptInfo(fileName string) {
316+
path := p.toPath(fileName)
317+
if p.kind == KindConfigured {
318+
if p.rootFileNames.Has(path) || p.parsedCommandLine.MatchesFileName(fileName) {
319+
p.pendingReload = PendingReloadFileNames
320+
p.markAsDirty()
321+
return
322+
}
323+
}
324+
325+
if _, ok := p.failedLookupsWatch.data[path]; ok {
326+
p.markAsDirty()
327+
} else if _, ok := p.affectingLocationsWatch.data[path]; ok {
328+
p.markAsDirty()
329+
}
330+
}
331+
208332
func (p *Project) getOrCreateScriptInfoAndAttachToProject(fileName string, scriptKind core.ScriptKind) *ScriptInfo {
209333
if scriptInfo := p.host.GetOrCreateScriptInfoForFile(fileName, p.toPath(fileName), scriptKind); scriptInfo != nil {
210334
scriptInfo.attachToProject(p)
@@ -232,6 +356,7 @@ func (p *Project) markAsDirty() {
232356
}
233357
}
234358

359+
// updateIfDirty returns true if the project was updated.
235360
func (p *Project) updateIfDirty() bool {
236361
// !!! p.invalidateResolutionsOfFailedLookupLocations()
237362
return p.dirty && p.updateGraph()
@@ -257,11 +382,17 @@ func (p *Project) updateGraph() bool {
257382
hasAddedOrRemovedFiles := p.hasAddedOrRemovedFiles
258383
p.initialLoadPending = false
259384

260-
if p.kind == KindConfigured && p.reloadConfig {
261-
if err := p.LoadConfig(); err != nil {
262-
panic(fmt.Sprintf("failed to reload config: %v", err))
385+
if p.kind == KindConfigured && p.pendingReload != PendingReloadNone {
386+
switch p.pendingReload {
387+
case PendingReloadFileNames:
388+
p.parsedCommandLine = tsoptions.ReloadFileNamesOfParsedCommandLine(p.parsedCommandLine, p.host.FS())
389+
p.setRootFiles(p.parsedCommandLine.FileNames())
390+
case PendingReloadFull:
391+
if err := p.LoadConfig(); err != nil {
392+
panic(fmt.Sprintf("failed to reload config: %v", err))
393+
}
263394
}
264-
p.reloadConfig = false
395+
p.pendingReload = PendingReloadNone
265396
}
266397

267398
p.hasAddedOrRemovedFiles = false
@@ -283,6 +414,7 @@ func (p *Project) updateGraph() bool {
283414
}
284415
}
285416

417+
p.updateWatchers()
286418
return true
287419
}
288420

@@ -324,7 +456,7 @@ func (p *Project) removeFile(info *ScriptInfo, fileExists bool, detachFromProjec
324456
case KindInferred:
325457
p.rootFileNames.Delete(info.path)
326458
case KindConfigured:
327-
p.reloadConfig = true
459+
p.pendingReload = PendingReloadFileNames
328460
}
329461
}
330462

@@ -384,6 +516,7 @@ func (p *Project) LoadConfig() error {
384516
}, " ", " ")),
385517
)
386518

519+
p.parsedCommandLine = parsedCommandLine
387520
p.compilerOptions = parsedCommandLine.CompilerOptions()
388521
p.setRootFiles(parsedCommandLine.FileNames())
389522
} else {
@@ -399,16 +532,21 @@ func (p *Project) setRootFiles(rootFileNames []string) {
399532
newRootScriptInfos := make(map[tspath.Path]struct{}, len(rootFileNames))
400533
for _, file := range rootFileNames {
401534
scriptKind := p.getScriptKind(file)
402-
scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, p.toPath(file), scriptKind)
403-
newRootScriptInfos[scriptInfo.path] = struct{}{}
404-
if _, isRoot := p.rootFileNames.Get(scriptInfo.path); !isRoot {
535+
path := p.toPath(file)
536+
// !!! updateNonInferredProjectFiles uses a fileExists check, which I guess
537+
// could be needed if a watcher fails?
538+
scriptInfo := p.host.GetOrCreateScriptInfoForFile(file, path, scriptKind)
539+
newRootScriptInfos[path] = struct{}{}
540+
isAlreadyRoot := p.rootFileNames.Has(path)
541+
542+
if !isAlreadyRoot && scriptInfo != nil {
405543
p.addRoot(scriptInfo)
406544
if scriptInfo.isOpen {
407545
// !!!
408546
// s.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo)
409547
}
410-
} else {
411-
p.rootFileNames.Set(scriptInfo.path, file)
548+
} else if !isAlreadyRoot {
549+
p.rootFileNames.Set(path, file)
412550
}
413551
}
414552

@@ -451,7 +589,7 @@ func (p *Project) print(writeFileNames bool, writeFileExplanation bool, writeFil
451589
// if writeFileExplanation {}
452590
}
453591
}
454-
builder.WriteString("-----------------------------------------------")
592+
builder.WriteString(hr)
455593
return builder.String()
456594
}
457595

@@ -466,3 +604,19 @@ func (p *Project) logf(format string, args ...interface{}) {
466604
func (p *Project) Close() {
467605
// !!!
468606
}
607+
608+
func formatFileList(files []string, linePrefix string, groupSuffix string) string {
609+
var builder strings.Builder
610+
length := len(groupSuffix)
611+
for _, file := range files {
612+
length += len(file) + len(linePrefix) + 1
613+
}
614+
builder.Grow(length)
615+
for _, file := range files {
616+
builder.WriteString(linePrefix)
617+
builder.WriteString(file)
618+
builder.WriteRune('\n')
619+
}
620+
builder.WriteString(groupSuffix)
621+
return builder.String()
622+
}

‎internal/project/scriptinfo.go

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ type ScriptInfo struct {
2727
deferredDelete bool
2828

2929
containingProjects []*Project
30+
31+
fs vfs.FS
3032
}
3133

32-
func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind) *ScriptInfo {
34+
func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind, fs vfs.FS) *ScriptInfo {
3335
isDynamic := isDynamicFileName(fileName)
3436
realpath := core.IfElse(isDynamic, path, "")
3537
return &ScriptInfo{
@@ -38,6 +40,7 @@ func NewScriptInfo(fileName string, path tspath.Path, scriptKind core.ScriptKind
3840
realpath: realpath,
3941
isDynamic: isDynamic,
4042
scriptKind: scriptKind,
43+
fs: fs,
4144
}
4245
}
4346

@@ -51,15 +54,29 @@ func (s *ScriptInfo) Path() tspath.Path {
5154

5255
func (s *ScriptInfo) LineMap() *ls.LineMap {
5356
if s.lineMap == nil {
54-
s.lineMap = ls.ComputeLineStarts(s.text)
57+
s.lineMap = ls.ComputeLineStarts(s.Text())
5558
}
5659
return s.lineMap
5760
}
5861

5962
func (s *ScriptInfo) Text() string {
63+
s.reloadIfNeeded()
6064
return s.text
6165
}
6266

67+
func (s *ScriptInfo) Version() int {
68+
s.reloadIfNeeded()
69+
return s.version
70+
}
71+
72+
func (s *ScriptInfo) reloadIfNeeded() {
73+
if s.pendingReloadFromDisk {
74+
if newText, ok := s.fs.ReadFile(s.fileName); ok {
75+
s.SetTextFromDisk(newText)
76+
}
77+
}
78+
}
79+
6380
func (s *ScriptInfo) open(newText string) {
6481
s.isOpen = true
6582
s.pendingReloadFromDisk = false
@@ -133,7 +150,7 @@ func (s *ScriptInfo) isOrphan() bool {
133150
}
134151

135152
func (s *ScriptInfo) editContent(change ls.TextChange) {
136-
s.setText(change.ApplyTo(s.text))
153+
s.setText(change.ApplyTo(s.Text()))
137154
s.markContainingProjectsAsDirty()
138155
}
139156

‎internal/project/service.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type assignProjectResult struct {
3030
type ServiceOptions struct {
3131
Logger *Logger
3232
PositionEncoding lsproto.PositionEncodingKind
33+
WatchEnabled bool
3334
}
3435

3536
var _ ProjectHost = (*Service)(nil)
@@ -38,6 +39,7 @@ type Service struct {
3839
host ServiceHost
3940
options ServiceOptions
4041
comparePathsOptions tspath.ComparePathsOptions
42+
converters *ls.Converters
4143

4244
configuredProjects map[tspath.Path]*Project
4345
// unrootedInferredProject is the inferred project for files opened without a projectRootDirectory
@@ -61,7 +63,7 @@ type Service struct {
6163
func NewService(host ServiceHost, options ServiceOptions) *Service {
6264
options.Logger.Info(fmt.Sprintf("currentDirectory:: %s useCaseSensitiveFileNames:: %t", host.GetCurrentDirectory(), host.FS().UseCaseSensitiveFileNames()))
6365
options.Logger.Info("libs Location:: " + host.DefaultLibraryPath())
64-
return &Service{
66+
service := &Service{
6567
host: host,
6668
options: options,
6769
comparePathsOptions: tspath.ComparePathsOptions{
@@ -82,6 +84,12 @@ func NewService(host ServiceHost, options ServiceOptions) *Service {
8284
filenameToScriptInfoVersion: make(map[tspath.Path]int),
8385
realpathToScriptInfos: make(map[tspath.Path]map[*ScriptInfo]struct{}),
8486
}
87+
88+
service.converters = ls.NewConverters(options.PositionEncoding, func(fileName string) ls.ScriptInfo {
89+
return service.GetScriptInfo(fileName)
90+
})
91+
92+
return service
8593
}
8694

8795
// GetCurrentDirectory implements ProjectHost.
@@ -124,6 +132,16 @@ func (s *Service) PositionEncoding() lsproto.PositionEncodingKind {
124132
return s.options.PositionEncoding
125133
}
126134

135+
// Client implements ProjectHost.
136+
func (s *Service) Client() Client {
137+
return s.host.Client()
138+
}
139+
140+
// IsWatchEnabled implements ProjectHost.
141+
func (s *Service) IsWatchEnabled() bool {
142+
return s.options.WatchEnabled
143+
}
144+
127145
func (s *Service) Projects() []*Project {
128146
projects := make([]*Project, 0, len(s.configuredProjects)+len(s.inferredProjects))
129147
for _, project := range s.configuredProjects {
@@ -215,6 +233,62 @@ func (s *Service) SourceFileCount() int {
215233
return s.documentRegistry.size()
216234
}
217235

236+
func (s *Service) OnWatchedFilesChanged(changes []*lsproto.FileEvent) error {
237+
for _, change := range changes {
238+
fileName := ls.DocumentURIToFileName(change.Uri)
239+
path := s.toPath(fileName)
240+
if project, ok := s.configuredProjects[path]; ok {
241+
// tsconfig of project
242+
if err := s.onConfigFileChanged(project, change.Type); err != nil {
243+
return fmt.Errorf("error handling config file change: %w", err)
244+
}
245+
} else if _, ok := s.openFiles[path]; ok {
246+
// open file
247+
continue
248+
} else if info := s.GetScriptInfoByPath(path); info != nil {
249+
// closed existing file
250+
if change.Type == lsproto.FileChangeTypeDeleted {
251+
s.handleDeletedFile(info, true /*deferredDelete*/)
252+
} else {
253+
info.deferredDelete = false
254+
info.delayReloadNonMixedContentFile()
255+
// !!! s.delayUpdateProjectGraphs(info.containingProjects, false /*clearSourceMapperCache*/)
256+
// !!! s.handleSourceMapProjects(info)
257+
}
258+
} else {
259+
for _, project := range s.configuredProjects {
260+
project.onWatchEventForNilScriptInfo(fileName)
261+
}
262+
}
263+
}
264+
265+
client := s.host.Client()
266+
if client != nil {
267+
return client.RefreshDiagnostics()
268+
}
269+
270+
return nil
271+
}
272+
273+
func (s *Service) onConfigFileChanged(project *Project, changeKind lsproto.FileChangeType) error {
274+
wasDeferredClose := project.deferredClose
275+
switch changeKind {
276+
case lsproto.FileChangeTypeCreated:
277+
if wasDeferredClose {
278+
project.deferredClose = false
279+
}
280+
case lsproto.FileChangeTypeDeleted:
281+
project.deferredClose = true
282+
}
283+
284+
s.delayUpdateProjectGraph(project)
285+
if !project.deferredClose {
286+
project.pendingReload = PendingReloadFull
287+
project.markAsDirty()
288+
}
289+
return nil
290+
}
291+
218292
func (s *Service) ensureProjectStructureUpToDate() {
219293
var hasChanges bool
220294
for _, project := range s.configuredProjects {
@@ -351,7 +425,7 @@ func (s *Service) getOrCreateScriptInfoWorker(fileName string, path tspath.Path,
351425
}
352426
}
353427

354-
info = NewScriptInfo(fileName, path, scriptKind)
428+
info = NewScriptInfo(fileName, path, scriptKind, s.host.FS())
355429
if fromDisk {
356430
info.SetTextFromDisk(fileContent)
357431
}

‎internal/project/service_test.go

Lines changed: 337 additions & 46 deletions
Large diffs are not rendered by default.

‎internal/project/watch.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package project
2+
3+
import (
4+
"slices"
5+
6+
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
7+
)
8+
9+
const (
10+
fileGlobPattern = "*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}"
11+
recursiveFileGlobPattern = "**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}"
12+
)
13+
14+
type watchedFiles[T any] struct {
15+
client Client
16+
getGlobs func(data T) []string
17+
watchKind lsproto.WatchKind
18+
19+
data T
20+
globs []string
21+
watcherID WatcherHandle
22+
}
23+
24+
func newWatchedFiles[T any](client Client, watchKind lsproto.WatchKind, getGlobs func(data T) []string) *watchedFiles[T] {
25+
return &watchedFiles[T]{
26+
client: client,
27+
watchKind: watchKind,
28+
getGlobs: getGlobs,
29+
}
30+
}
31+
32+
func (w *watchedFiles[T]) update(newData T) (updated bool, err error) {
33+
newGlobs := w.getGlobs(newData)
34+
w.data = newData
35+
if slices.Equal(w.globs, newGlobs) {
36+
return false, nil
37+
}
38+
39+
w.globs = newGlobs
40+
if w.watcherID != "" {
41+
if err = w.client.UnwatchFiles(w.watcherID); err != nil {
42+
return false, err
43+
}
44+
}
45+
46+
watchers := make([]*lsproto.FileSystemWatcher, 0, len(newGlobs))
47+
for _, glob := range newGlobs {
48+
watchers = append(watchers, &lsproto.FileSystemWatcher{
49+
GlobPattern: lsproto.PatternOrRelativePattern{
50+
Pattern: &glob,
51+
},
52+
Kind: &w.watchKind,
53+
})
54+
}
55+
watcherID, err := w.client.WatchFiles(watchers)
56+
if err != nil {
57+
return false, err
58+
}
59+
w.watcherID = watcherID
60+
return true, nil
61+
}

‎internal/testrunner/test_case_parser.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func makeUnitsFromTest(code string, fileName string) testCaseContent {
122122
for _, data := range testUnits {
123123
allFiles[tspath.GetNormalizedAbsolutePath(data.name, currentDirectory)] = data.content
124124
}
125-
parseConfigHost := tsoptionstest.NewVFSParseConfigHost(allFiles, currentDirectory)
125+
parseConfigHost := tsoptionstest.NewVFSParseConfigHost(allFiles, currentDirectory, true /*useCaseSensitiveFileNames*/)
126126

127127
// check if project has tsconfig.json in the list of files
128128
var tsConfig *tsoptions.ParsedCommandLine

‎internal/testutil/projecttestutil/clientmock_generated.go

Lines changed: 168 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎internal/testutil/projecttestutil/projecttestutil.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,15 @@ import (
1212
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
1313
)
1414

15+
//go:generate go tool github.com/matryer/moq -stub -fmt goimports -pkg projecttestutil -out clientmock_generated.go ../../project Client
16+
1517
type ProjectServiceHost struct {
1618
fs vfs.FS
1719
mu sync.Mutex
1820
defaultLibraryPath string
1921
output strings.Builder
2022
logger *project.Logger
23+
ClientMock *ClientMock
2124
}
2225

2326
// DefaultLibraryPath implements project.ProjectServiceHost.
@@ -47,6 +50,11 @@ func (p *ProjectServiceHost) NewLine() string {
4750
return "\n"
4851
}
4952

53+
// Client implements project.ProjectServiceHost.
54+
func (p *ProjectServiceHost) Client() project.Client {
55+
return p.ClientMock
56+
}
57+
5058
func (p *ProjectServiceHost) ReplaceFS(files map[string]string) {
5159
p.fs = bundled.WrapFS(vfstest.FromMap(files, false /*useCaseSensitiveFileNames*/))
5260
}
@@ -56,7 +64,8 @@ var _ project.ServiceHost = (*ProjectServiceHost)(nil)
5664
func Setup(files map[string]string) (*project.Service, *ProjectServiceHost) {
5765
host := newProjectServiceHost(files)
5866
service := project.NewService(host, project.ServiceOptions{
59-
Logger: host.logger,
67+
Logger: host.logger,
68+
WatchEnabled: true,
6069
})
6170
return service, host
6271
}
@@ -66,6 +75,7 @@ func newProjectServiceHost(files map[string]string) *ProjectServiceHost {
6675
host := &ProjectServiceHost{
6776
fs: fs,
6877
defaultLibraryPath: bundled.LibPath(),
78+
ClientMock: &ClientMock{},
6979
}
7080
host.logger = project.NewLogger([]io.Writer{&host.output}, "", project.LogLevelVerbose)
7181
return host

‎internal/tsoptions/commandlineparser.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/microsoft/typescript-go/internal/core"
1010
"github.com/microsoft/typescript-go/internal/diagnostics"
1111
"github.com/microsoft/typescript-go/internal/stringutil"
12+
"github.com/microsoft/typescript-go/internal/tspath"
1213
"github.com/microsoft/typescript-go/internal/vfs"
1314
)
1415

@@ -58,6 +59,11 @@ func ParseCommandLine(
5859
Errors: parser.errors,
5960
Raw: parser.options, // !!! keep optionsBase incase needed later. todo: figure out if this is still needed
6061
CompileOnSave: nil,
62+
63+
comparePathsOptions: tspath.ComparePathsOptions{
64+
UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(),
65+
CurrentDirectory: host.GetCurrentDirectory(),
66+
},
6167
}
6268
}
6369

‎internal/tsoptions/parsedcommandline.go

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,52 @@ package tsoptions
22

33
import (
44
"slices"
5+
"sync"
56

67
"github.com/microsoft/typescript-go/internal/ast"
78
"github.com/microsoft/typescript-go/internal/core"
9+
"github.com/microsoft/typescript-go/internal/tspath"
10+
"github.com/microsoft/typescript-go/internal/vfs"
811
)
912

1013
type ParsedCommandLine struct {
1114
ParsedConfig *core.ParsedOptions `json:"parsedConfig"`
1215

13-
ConfigFile *TsConfigSourceFile `json:"configFile"` // TsConfigSourceFile, used in Program and ExecuteCommandLine
14-
Errors []*ast.Diagnostic `json:"errors"`
15-
Raw any `json:"raw"`
16-
// WildcardDirectories map[string]watchDirectoryFlags
17-
CompileOnSave *bool `json:"compileOnSave"`
16+
ConfigFile *TsConfigSourceFile `json:"configFile"` // TsConfigSourceFile, used in Program and ExecuteCommandLine
17+
Errors []*ast.Diagnostic `json:"errors"`
18+
Raw any `json:"raw"`
19+
CompileOnSave *bool `json:"compileOnSave"`
1820
// TypeAquisition *core.TypeAcquisition
21+
22+
comparePathsOptions tspath.ComparePathsOptions
23+
wildcardDirectoriesOnce sync.Once
24+
wildcardDirectories map[string]bool
25+
extraFileExtensions []fileExtensionInfo
26+
}
27+
28+
// WildcardDirectories returns the cached wildcard directories, initializing them if needed
29+
func (p *ParsedCommandLine) WildcardDirectories() map[string]bool {
30+
if p.wildcardDirectories != nil {
31+
return p.wildcardDirectories
32+
}
33+
34+
p.wildcardDirectoriesOnce.Do(func() {
35+
p.wildcardDirectories = getWildcardDirectories(
36+
p.ConfigFile.configFileSpecs.validatedIncludeSpecs,
37+
p.ConfigFile.configFileSpecs.validatedExcludeSpecs,
38+
p.comparePathsOptions,
39+
)
40+
})
41+
42+
return p.wildcardDirectories
43+
}
44+
45+
// Normalized file names explicitly specified in `files`
46+
func (p *ParsedCommandLine) LiteralFileNames() []string {
47+
if p.ConfigFile != nil {
48+
return p.FileNames()[0:len(p.ConfigFile.configFileSpecs.validatedFilesSpec)]
49+
}
50+
return nil
1951
}
2052

2153
func (p *ParsedCommandLine) SetParsedOptions(o *core.ParsedOptions) {
@@ -30,6 +62,7 @@ func (p *ParsedCommandLine) CompilerOptions() *core.CompilerOptions {
3062
return p.ParsedConfig.CompilerOptions
3163
}
3264

65+
// All file names matched by files, include, and exclude patterns
3366
func (p *ParsedCommandLine) FileNames() []string {
3467
return p.ParsedConfig.FileNames
3568
}
@@ -45,3 +78,69 @@ func (p *ParsedCommandLine) GetConfigFileParsingDiagnostics() []*ast.Diagnostic
4578
}
4679
return p.Errors
4780
}
81+
82+
// Porting reference: ProjectService.isMatchedByConfig
83+
func (p *ParsedCommandLine) MatchesFileName(fileName string) bool {
84+
path := tspath.ToPath(fileName, p.comparePathsOptions.CurrentDirectory, p.comparePathsOptions.UseCaseSensitiveFileNames)
85+
if slices.ContainsFunc(p.FileNames(), func(f string) bool {
86+
return path == tspath.ToPath(f, p.comparePathsOptions.CurrentDirectory, p.comparePathsOptions.UseCaseSensitiveFileNames)
87+
}) {
88+
return true
89+
}
90+
91+
if p.ConfigFile == nil {
92+
return false
93+
}
94+
95+
if len(p.ConfigFile.configFileSpecs.validatedIncludeSpecs) == 0 {
96+
return false
97+
}
98+
99+
supportedExtensions := GetSupportedExtensionsWithJsonIfResolveJsonModule(
100+
p.CompilerOptions(),
101+
GetSupportedExtensions(p.CompilerOptions(), p.extraFileExtensions),
102+
)
103+
104+
if !tspath.FileExtensionIsOneOf(fileName, core.Flatten(supportedExtensions)) {
105+
return false
106+
}
107+
108+
if p.ConfigFile.configFileSpecs.matchesExclude(fileName, p.comparePathsOptions) {
109+
return false
110+
}
111+
112+
var allFileNames core.Set[tspath.Path]
113+
for _, fileName := range p.FileNames() {
114+
allFileNames.Add(tspath.ToPath(fileName, p.comparePathsOptions.CurrentDirectory, p.comparePathsOptions.UseCaseSensitiveFileNames))
115+
}
116+
117+
if hasFileWithHigherPriorityExtension(string(path), supportedExtensions, func(fileName string) bool {
118+
return allFileNames.Has(tspath.Path(fileName))
119+
}) {
120+
return false
121+
}
122+
123+
return p.ConfigFile.configFileSpecs.matchesInclude(fileName, p.comparePathsOptions)
124+
}
125+
126+
func ReloadFileNamesOfParsedCommandLine(p *ParsedCommandLine, fs vfs.FS) *ParsedCommandLine {
127+
parsedConfig := *p.ParsedConfig
128+
parsedConfig.FileNames = getFileNamesFromConfigSpecs(
129+
*p.ConfigFile.configFileSpecs,
130+
p.comparePathsOptions.CurrentDirectory,
131+
p.CompilerOptions(),
132+
fs,
133+
p.extraFileExtensions,
134+
)
135+
parsedCommandLine := ParsedCommandLine{
136+
ParsedConfig: &parsedConfig,
137+
ConfigFile: p.ConfigFile,
138+
Errors: p.Errors,
139+
Raw: p.Raw,
140+
CompileOnSave: p.CompileOnSave,
141+
comparePathsOptions: p.comparePathsOptions,
142+
wildcardDirectories: p.wildcardDirectories,
143+
extraFileExtensions: p.extraFileExtensions,
144+
}
145+
return &parsedCommandLine
146+
}
Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
1+
package tsoptions_test
2+
3+
import (
4+
"slices"
5+
"testing"
6+
7+
"github.com/microsoft/typescript-go/internal/tsoptions"
8+
"github.com/microsoft/typescript-go/internal/tsoptions/tsoptionstest"
9+
"github.com/microsoft/typescript-go/internal/vfs/vfstest"
10+
"gotest.tools/v3/assert"
11+
)
12+
13+
func TestParsedCommandLine(t *testing.T) {
14+
t.Parallel()
15+
t.Run("MatchesFileName", func(t *testing.T) {
16+
t.Parallel()
17+
18+
noFiles := map[string]string{}
19+
noFilesFS := vfstest.FromMap(noFiles, true)
20+
21+
files := map[string]string{
22+
"/dev/a.ts": "",
23+
"/dev/a.d.ts": "",
24+
"/dev/a.js": "",
25+
"/dev/b.ts": "",
26+
"/dev/b.js": "",
27+
"/dev/c.d.ts": "",
28+
"/dev/z/a.ts": "",
29+
"/dev/z/abz.ts": "",
30+
"/dev/z/aba.ts": "",
31+
"/dev/z/b.ts": "",
32+
"/dev/z/bbz.ts": "",
33+
"/dev/z/bba.ts": "",
34+
"/dev/x/a.ts": "",
35+
"/dev/x/aa.ts": "",
36+
"/dev/x/b.ts": "",
37+
"/dev/x/y/a.ts": "",
38+
"/dev/x/y/b.ts": "",
39+
"/dev/js/a.js": "",
40+
"/dev/js/b.js": "",
41+
"/dev/js/d.min.js": "",
42+
"/dev/js/ab.min.js": "",
43+
"/ext/ext.ts": "",
44+
"/ext/b/a..b.ts": "",
45+
}
46+
47+
assertMatches := func(t *testing.T, parsedCommandLine *tsoptions.ParsedCommandLine, files map[string]string, matches []string) {
48+
t.Helper()
49+
for fileName := range files {
50+
actual := parsedCommandLine.MatchesFileName(fileName)
51+
expected := slices.Contains(matches, fileName)
52+
assert.Equal(t, actual, expected, "fileName: %s", fileName)
53+
}
54+
for _, fileName := range matches {
55+
if _, ok := files[fileName]; !ok {
56+
actual := parsedCommandLine.MatchesFileName(fileName)
57+
assert.Equal(t, actual, true, "fileName: %s", fileName)
58+
}
59+
}
60+
}
61+
62+
t.Run("with literal file list", func(t *testing.T) {
63+
t.Parallel()
64+
t.Run("without exclude", func(t *testing.T) {
65+
t.Parallel()
66+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
67+
t,
68+
`{
69+
"files": [
70+
"a.ts",
71+
"b.ts"
72+
]
73+
}`,
74+
files,
75+
"/dev",
76+
/*useCaseSensitiveFileNames*/ true,
77+
)
78+
79+
assertMatches(t, parsedCommandLine, files, []string{
80+
"/dev/a.ts",
81+
"/dev/b.ts",
82+
})
83+
})
84+
85+
t.Run("are not removed due to excludes", func(t *testing.T) {
86+
t.Parallel()
87+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
88+
t,
89+
`{
90+
"files": [
91+
"a.ts",
92+
"b.ts"
93+
],
94+
"exclude": [
95+
"b.ts"
96+
]
97+
}`,
98+
files,
99+
"/dev",
100+
/*useCaseSensitiveFileNames*/ true,
101+
)
102+
103+
assertMatches(t, parsedCommandLine, files, []string{
104+
"/dev/a.ts",
105+
"/dev/b.ts",
106+
})
107+
108+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
109+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
110+
"/dev/a.ts",
111+
"/dev/b.ts",
112+
})
113+
})
114+
})
115+
116+
t.Run("with literal include list", func(t *testing.T) {
117+
t.Parallel()
118+
t.Run("without exclude", func(t *testing.T) {
119+
t.Parallel()
120+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
121+
t,
122+
`{
123+
"include": [
124+
"a.ts",
125+
"b.ts"
126+
]
127+
}`,
128+
files,
129+
"/dev",
130+
/*useCaseSensitiveFileNames*/ true,
131+
)
132+
133+
assertMatches(t, parsedCommandLine, files, []string{
134+
"/dev/a.ts",
135+
"/dev/b.ts",
136+
})
137+
138+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
139+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
140+
"/dev/a.ts",
141+
"/dev/b.ts",
142+
})
143+
})
144+
145+
t.Run("with non .ts file extensions", func(t *testing.T) {
146+
t.Parallel()
147+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
148+
t,
149+
`{
150+
"include": [
151+
"a.js",
152+
"b.js"
153+
]
154+
}`,
155+
files,
156+
"/dev",
157+
/*useCaseSensitiveFileNames*/ true,
158+
)
159+
160+
assertMatches(t, parsedCommandLine, files, []string{})
161+
162+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
163+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{})
164+
})
165+
166+
t.Run("with literal excludes", func(t *testing.T) {
167+
t.Parallel()
168+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
169+
t,
170+
`{
171+
"include": [
172+
"a.ts",
173+
"b.ts"
174+
],
175+
"exclude": [
176+
"b.ts"
177+
]
178+
}`,
179+
files,
180+
"/dev",
181+
/*useCaseSensitiveFileNames*/ true,
182+
)
183+
184+
assertMatches(t, parsedCommandLine, files, []string{
185+
"/dev/a.ts",
186+
})
187+
188+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
189+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
190+
"/dev/a.ts",
191+
})
192+
})
193+
194+
t.Run("with wildcard excludes", func(t *testing.T) {
195+
t.Parallel()
196+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
197+
t,
198+
`{
199+
"include": [
200+
"a.ts",
201+
"b.ts",
202+
"z/a.ts",
203+
"z/abz.ts",
204+
"z/aba.ts",
205+
"x/b.ts"
206+
],
207+
"exclude": [
208+
"*.ts",
209+
"z/??z.ts",
210+
"*/b.ts"
211+
]
212+
}`,
213+
files,
214+
"/dev",
215+
/*useCaseSensitiveFileNames*/ true,
216+
)
217+
218+
assertMatches(t, parsedCommandLine, files, []string{
219+
"/dev/z/a.ts",
220+
"/dev/z/aba.ts",
221+
})
222+
223+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
224+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
225+
"/dev/z/a.ts",
226+
"/dev/z/aba.ts",
227+
})
228+
})
229+
230+
t.Run("with wildcard include list", func(t *testing.T) {
231+
t.Parallel()
232+
233+
t.Run("star matches only ts files", func(t *testing.T) {
234+
t.Parallel()
235+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
236+
t,
237+
`{
238+
"include": [
239+
"*"
240+
]
241+
}`,
242+
files,
243+
"/dev",
244+
/*useCaseSensitiveFileNames*/ true,
245+
)
246+
247+
assertMatches(t, parsedCommandLine, files, []string{
248+
"/dev/a.ts",
249+
"/dev/b.ts",
250+
"/dev/c.d.ts",
251+
})
252+
253+
// a.d.ts matches if a.ts is not already included
254+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
255+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
256+
"/dev/a.ts",
257+
"/dev/a.d.ts",
258+
"/dev/b.ts",
259+
"/dev/c.d.ts",
260+
})
261+
})
262+
263+
t.Run("question matches only a single character", func(t *testing.T) {
264+
t.Parallel()
265+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
266+
t,
267+
`{
268+
"include": [
269+
"x/?.ts"
270+
]
271+
}`,
272+
files,
273+
"/dev",
274+
/*useCaseSensitiveFileNames*/ true,
275+
)
276+
277+
assertMatches(t, parsedCommandLine, files, []string{
278+
"/dev/x/a.ts",
279+
"/dev/x/b.ts",
280+
})
281+
282+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
283+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
284+
"/dev/x/a.ts",
285+
"/dev/x/b.ts",
286+
})
287+
})
288+
289+
t.Run("exclude .js files when allowJs=false", func(t *testing.T) {
290+
t.Parallel()
291+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
292+
t,
293+
`{
294+
"include": [
295+
"js/*"
296+
]
297+
}`,
298+
files,
299+
"/dev",
300+
/*useCaseSensitiveFileNames*/ true,
301+
)
302+
303+
assertMatches(t, parsedCommandLine, files, []string{})
304+
305+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
306+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{})
307+
})
308+
309+
t.Run("include .js files when allowJs=true", func(t *testing.T) {
310+
t.Parallel()
311+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
312+
t,
313+
`{
314+
"compilerOptions": {
315+
"allowJs": true
316+
},
317+
"include": [
318+
"js/*"
319+
]
320+
}`,
321+
files,
322+
"/dev",
323+
/*useCaseSensitiveFileNames*/ true,
324+
)
325+
326+
assertMatches(t, parsedCommandLine, files, []string{
327+
"/dev/js/a.js",
328+
"/dev/js/b.js",
329+
})
330+
331+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
332+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
333+
"/dev/js/a.js",
334+
"/dev/js/b.js",
335+
})
336+
})
337+
338+
t.Run("include explicitly listed .min.js files when allowJs=true", func(t *testing.T) {
339+
t.Parallel()
340+
parsedCommandLine := tsoptionstest.GetParsedCommandLine(
341+
t,
342+
`{
343+
"compilerOptions": {
344+
"allowJs": true
345+
},
346+
"include": [
347+
"js/*.min.js"
348+
]
349+
}`,
350+
files,
351+
"/dev",
352+
/*useCaseSensitiveFileNames*/ true,
353+
)
354+
355+
assertMatches(t, parsedCommandLine, files, []string{
356+
"/dev/js/d.min.js",
357+
"/dev/js/ab.min.js",
358+
})
359+
360+
emptyParsedCommandLine := tsoptions.ReloadFileNamesOfParsedCommandLine(parsedCommandLine, noFilesFS)
361+
assertMatches(t, emptyParsedCommandLine, noFiles, []string{
362+
"/dev/js/d.min.js",
363+
"/dev/js/ab.min.js",
364+
})
365+
})
366+
})
367+
})
368+
})
369+
}

‎internal/tsoptions/tsconfigparsing.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,15 +97,51 @@ type configFileSpecs struct {
9797
validatedExcludeSpecs []string
9898
isDefaultIncludeSpec bool
9999
}
100+
101+
func (c *configFileSpecs) matchesExclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool {
102+
if len(c.validatedExcludeSpecs) == 0 {
103+
return false
104+
}
105+
excludePattern := getRegularExpressionForWildcard(c.validatedExcludeSpecs, comparePathsOptions.CurrentDirectory, "exclude")
106+
excludeRegex := getRegexFromPattern(excludePattern, comparePathsOptions.UseCaseSensitiveFileNames)
107+
if match, err := excludeRegex.MatchString(fileName); err == nil && match {
108+
return true
109+
}
110+
if !tspath.HasExtension(fileName) {
111+
if match, err := excludeRegex.MatchString(tspath.EnsureTrailingDirectorySeparator(fileName)); err == nil && match {
112+
return true
113+
}
114+
}
115+
return false
116+
}
117+
118+
func (c *configFileSpecs) matchesInclude(fileName string, comparePathsOptions tspath.ComparePathsOptions) bool {
119+
if len(c.validatedIncludeSpecs) == 0 {
120+
return false
121+
}
122+
for _, spec := range c.validatedIncludeSpecs {
123+
includePattern := getPatternFromSpec(spec, comparePathsOptions.CurrentDirectory, "files")
124+
if includePattern != "" {
125+
includeRegex := getRegexFromPattern(includePattern, comparePathsOptions.UseCaseSensitiveFileNames)
126+
if match, err := includeRegex.MatchString(fileName); err == nil && match {
127+
return true
128+
}
129+
}
130+
}
131+
return false
132+
}
133+
100134
type fileExtensionInfo struct {
101135
extension string
102136
isMixedContent bool
103137
scriptKind core.ScriptKind
104138
}
139+
105140
type ExtendedConfigCacheEntry struct {
106141
extendedResult *TsConfigSourceFile
107142
extendedConfig *parsedTsconfig
108143
}
144+
109145
type parsedTsconfig struct {
110146
raw any
111147
options *core.CompilerOptions
@@ -1209,6 +1245,12 @@ func parseJsonConfigFileContentWorker(
12091245
ConfigFile: sourceFile,
12101246
Raw: parsedConfig.raw,
12111247
Errors: errors,
1248+
1249+
extraFileExtensions: extraFileExtensions,
1250+
comparePathsOptions: tspath.ComparePathsOptions{
1251+
UseCaseSensitiveFileNames: host.FS().UseCaseSensitiveFileNames(),
1252+
CurrentDirectory: basePathForFileNames,
1253+
},
12121254
}
12131255
}
12141256

@@ -1389,7 +1431,7 @@ func handleOptionConfigDirTemplateSubstitution(compilerOptions *core.CompilerOpt
13891431

13901432
// hasFileWithHigherPriorityExtension determines whether a literal or wildcard file has already been included that has a higher extension priority.
13911433
// file is the path to the file.
1392-
func hasFileWithHigherPriorityExtension(file string, literalFiles collections.OrderedMap[string, string], wildcardFiles collections.OrderedMap[string, string], extensions [][]string, keyMapper func(value string) string) bool {
1434+
func hasFileWithHigherPriorityExtension(file string, extensions [][]string, hasFile func(fileName string) bool) bool {
13931435
var extensionGroup []string
13941436
for _, group := range extensions {
13951437
if tspath.FileExtensionIsOneOf(file, group) {
@@ -1406,8 +1448,7 @@ func hasFileWithHigherPriorityExtension(file string, literalFiles collections.Or
14061448
if tspath.FileExtensionIs(file, ext) && (ext != tspath.ExtensionTs || !tspath.FileExtensionIs(file, tspath.ExtensionDts)) {
14071449
return false
14081450
}
1409-
higherPriorityPath := keyMapper(tspath.ChangeExtension(file, ext))
1410-
if literalFiles.Has(higherPriorityPath) || wildcardFiles.Has(higherPriorityPath) {
1451+
if hasFile(tspath.ChangeExtension(file, ext)) {
14111452
if ext == tspath.ExtensionDts && (tspath.FileExtensionIs(file, tspath.ExtensionJs) || tspath.FileExtensionIs(file, tspath.ExtensionJsx)) {
14121453
// LEGACY BEHAVIOR: An off-by-one bug somewhere in the extension priority system for wildcard module loading allowed declaration
14131454
// files to be loaded alongside their js(x) counterparts. We regard this as generally undesirable, but retain the behavior to
@@ -1516,7 +1557,10 @@ func getFileNamesFromConfigSpecs(
15161557
// This handles cases where we may encounter both <file>.ts and
15171558
// <file>.d.ts (or <file>.js if "allowJs" is enabled) in the same
15181559
// directory when they are compilation outputs.
1519-
if hasFileWithHigherPriorityExtension(file, literalFileMap, wildcardFileMap, supportedExtensions, keyMappper) {
1560+
if hasFileWithHigherPriorityExtension(file, supportedExtensions, func(fileName string) bool {
1561+
canonicalFileName := keyMappper(fileName)
1562+
return literalFileMap.Has(canonicalFileName) || wildcardFileMap.Has(canonicalFileName)
1563+
}) {
15201564
continue
15211565
}
15221566
// We may have included a wildcard path with a lower priority

‎internal/tsoptions/tsconfigparsing_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ func baselineParseConfigWith(t *testing.T, baselineFileName string, noSubmoduleB
619619
allFileLists[file] = content
620620
}
621621
allFileLists[configFileName] = config.jsonText
622-
host := tsoptionstest.NewVFSParseConfigHost(allFileLists, config.basePath)
622+
host := tsoptionstest.NewVFSParseConfigHost(allFileLists, config.basePath, true /*useCaseSensitiveFileNames*/)
623623
parsedConfigFileContent := getParsed(config, host, basePath)
624624

625625
baselineContent.WriteString("Fs::\n")
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package tsoptionstest
2+
3+
import (
4+
"github.com/microsoft/typescript-go/internal/tsoptions"
5+
"github.com/microsoft/typescript-go/internal/tspath"
6+
"gotest.tools/v3/assert"
7+
)
8+
9+
func GetParsedCommandLine(t assert.TestingT, jsonText string, files map[string]string, currentDirectory string, useCaseSensitiveFileNames bool) *tsoptions.ParsedCommandLine {
10+
host := NewVFSParseConfigHost(files, currentDirectory, useCaseSensitiveFileNames)
11+
configFileName := tspath.CombinePaths(currentDirectory, "tsconfig.json")
12+
tsconfigSourceFile := tsoptions.NewTsconfigSourceFileFromFilePath(configFileName, tspath.ToPath(configFileName, currentDirectory, useCaseSensitiveFileNames), jsonText)
13+
return tsoptions.ParseJsonSourceFileConfigFileContent(tsconfigSourceFile, host, currentDirectory, nil, configFileName, nil, nil, nil)
14+
}

‎internal/tsoptions/tsoptionstest/vfsparseconfighost.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ func (h *VfsParseConfigHost) GetCurrentDirectory() string {
3333
return h.CurrentDirectory
3434
}
3535

36-
func NewVFSParseConfigHost(files map[string]string, currentDirectory string) *VfsParseConfigHost {
36+
func NewVFSParseConfigHost(files map[string]string, currentDirectory string, useCaseSensitiveFileNames bool) *VfsParseConfigHost {
3737
return &VfsParseConfigHost{
38-
Vfs: vfstest.FromMap(files, true /*useCaseSensitiveFileNames*/),
38+
Vfs: vfstest.FromMap(files, useCaseSensitiveFileNames),
3939
CurrentDirectory: currentDirectory,
4040
}
4141
}

‎internal/tsoptions/utilities.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,19 @@ var wildcardMatchers = map[usage]WildcardMatcher{
150150
usageExclude: excludeMatcher,
151151
}
152152

153+
func getPatternFromSpec(
154+
spec string,
155+
basePath string,
156+
usage usage,
157+
) string {
158+
pattern := getSubPatternFromSpec(spec, basePath, usage, wildcardMatchers[usage])
159+
if pattern == "" {
160+
return ""
161+
}
162+
ending := core.IfElse(usage == "exclude", "($|/)", "$")
163+
return fmt.Sprintf("^(%s)%s", pattern, ending)
164+
}
165+
153166
func getSubPatternFromSpec(
154167
spec string,
155168
basePath string,
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package tsoptions
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
7+
"github.com/dlclark/regexp2"
8+
"github.com/microsoft/typescript-go/internal/tspath"
9+
)
10+
11+
func getWildcardDirectories(include []string, exclude []string, comparePathsOptions tspath.ComparePathsOptions) map[string]bool {
12+
// We watch a directory recursively if it contains a wildcard anywhere in a directory segment
13+
// of the pattern:
14+
//
15+
// /a/b/**/d - Watch /a/b recursively to catch changes to any d in any subfolder recursively
16+
// /a/b/*/d - Watch /a/b recursively to catch any d in any immediate subfolder, even if a new subfolder is added
17+
// /a/b - Watch /a/b recursively to catch changes to anything in any recursive subfoler
18+
//
19+
// We watch a directory without recursion if it contains a wildcard in the file segment of
20+
// the pattern:
21+
//
22+
// /a/b/* - Watch /a/b directly to catch any new file
23+
// /a/b/a?z - Watch /a/b directly to catch any new file matching a?z
24+
25+
if len(include) == 0 {
26+
return nil
27+
}
28+
29+
rawExcludeRegex := getRegularExpressionForWildcard(exclude, comparePathsOptions.CurrentDirectory, "exclude")
30+
var excludeRegex *regexp.Regexp
31+
if rawExcludeRegex != "" {
32+
options := ""
33+
if !comparePathsOptions.UseCaseSensitiveFileNames {
34+
options = "(?i)"
35+
}
36+
excludeRegex = regexp.MustCompile(options + rawExcludeRegex)
37+
}
38+
39+
wildcardDirectories := make(map[string]bool)
40+
wildCardKeyToPath := make(map[string]string)
41+
42+
var recursiveKeys []string
43+
44+
for _, file := range include {
45+
spec := tspath.NormalizeSlashes(tspath.CombinePaths(comparePathsOptions.CurrentDirectory, file))
46+
if excludeRegex != nil && excludeRegex.MatchString(spec) {
47+
continue
48+
}
49+
50+
match := getWildcardDirectoryFromSpec(spec, comparePathsOptions.UseCaseSensitiveFileNames)
51+
if match != nil {
52+
key := match.Key
53+
path := match.Path
54+
recursive := match.Recursive
55+
56+
existingPath, existsPath := wildCardKeyToPath[key]
57+
var existingRecursive bool
58+
59+
if existsPath {
60+
existingRecursive = wildcardDirectories[existingPath]
61+
}
62+
63+
if !existsPath || (!existingRecursive && recursive) {
64+
pathToUse := path
65+
if existsPath {
66+
pathToUse = existingPath
67+
}
68+
wildcardDirectories[pathToUse] = recursive
69+
70+
if !existsPath {
71+
wildCardKeyToPath[key] = path
72+
}
73+
74+
if recursive {
75+
recursiveKeys = append(recursiveKeys, key)
76+
}
77+
}
78+
}
79+
80+
// Remove any subpaths under an existing recursively watched directory
81+
for path := range wildcardDirectories {
82+
for _, recursiveKey := range recursiveKeys {
83+
key := toCanonicalKey(path, comparePathsOptions.UseCaseSensitiveFileNames)
84+
if key != recursiveKey && tspath.ContainsPath(recursiveKey, key, comparePathsOptions) {
85+
delete(wildcardDirectories, path)
86+
}
87+
}
88+
}
89+
}
90+
91+
return wildcardDirectories
92+
}
93+
94+
func toCanonicalKey(path string, useCaseSensitiveFileNames bool) string {
95+
if useCaseSensitiveFileNames {
96+
return path
97+
}
98+
return strings.ToLower(path)
99+
}
100+
101+
// wildcardDirectoryPattern matches paths with wildcard characters
102+
var wildcardDirectoryPattern = regexp2.MustCompile(`^[^*?]*(?=\/[^/]*[*?])`, 0)
103+
104+
// wildcardDirectoryMatch represents the result of a wildcard directory match
105+
type wildcardDirectoryMatch struct {
106+
Key string
107+
Path string
108+
Recursive bool
109+
}
110+
111+
func getWildcardDirectoryFromSpec(spec string, useCaseSensitiveFileNames bool) *wildcardDirectoryMatch {
112+
match, _ := wildcardDirectoryPattern.FindStringMatch(spec)
113+
if match != nil {
114+
// We check this with a few `Index` calls because it's more efficient than complex regex
115+
questionWildcardIndex := strings.Index(spec, "?")
116+
starWildcardIndex := strings.Index(spec, "*")
117+
lastDirectorySeparatorIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator)
118+
119+
// Determine if this should be watched recursively
120+
recursive := (questionWildcardIndex != -1 && questionWildcardIndex < lastDirectorySeparatorIndex) ||
121+
(starWildcardIndex != -1 && starWildcardIndex < lastDirectorySeparatorIndex)
122+
123+
return &wildcardDirectoryMatch{
124+
Key: toCanonicalKey(match.String(), useCaseSensitiveFileNames),
125+
Path: match.String(),
126+
Recursive: recursive,
127+
}
128+
}
129+
130+
if lastSepIndex := strings.LastIndexByte(spec, tspath.DirectorySeparator); lastSepIndex != -1 {
131+
lastSegment := spec[lastSepIndex+1:]
132+
if isImplicitGlob(lastSegment) {
133+
path := tspath.RemoveTrailingDirectorySeparator(spec)
134+
return &wildcardDirectoryMatch{
135+
Key: toCanonicalKey(path, useCaseSensitiveFileNames),
136+
Path: path,
137+
Recursive: true,
138+
}
139+
}
140+
}
141+
142+
return nil
143+
}

0 commit comments

Comments
 (0)
Please sign in to comment.