Skip to content

Commit ca643a8

Browse files
authored
Watch with way fewer globs in LSP (#971)
1 parent 6fc5cb8 commit ca643a8

File tree

3 files changed

+269
-9
lines changed

3 files changed

+269
-9
lines changed

internal/project/project.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package project
33
import (
44
"context"
55
"fmt"
6-
"maps"
76
"slices"
87
"strings"
98
"sync"
@@ -156,12 +155,9 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos
156155
}
157156
client := host.Client()
158157
if host.IsWatchEnabled() && client != nil {
159-
project.failedLookupsWatch = newWatchedFiles(client, lsproto.WatchKindCreate, func(data map[tspath.Path]string) []string {
160-
return slices.Sorted(maps.Values(data))
161-
})
162-
project.affectingLocationsWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, func(data map[tspath.Path]string) []string {
163-
return slices.Sorted(maps.Values(data))
164-
})
158+
globMapper := createGlobMapper(host)
159+
project.failedLookupsWatch = newWatchedFiles(client, lsproto.WatchKindCreate, globMapper)
160+
project.affectingLocationsWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, globMapper)
165161
}
166162
project.markAsDirty()
167163
return project
@@ -270,7 +266,7 @@ func (p *Project) getRootFileWatchGlobs() []string {
270266
result := make([]string, 0, len(globs)+1)
271267
result = append(result, p.configFileName)
272268
for dir, recursive := range globs {
273-
result = append(result, fmt.Sprintf("%s/%s", dir, core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern)))
269+
result = append(result, fmt.Sprintf("%s/%s", tspath.NormalizePath(dir), core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern)))
274270
}
275271
for _, fileName := range p.parsedCommandLine.LiteralFileNames() {
276272
result = append(result, fileName)

internal/project/service_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,7 @@ func TestService(t *testing.T) {
598598

599599
// Missing location should be watched
600600
assert.Check(t, slices.ContainsFunc(host.ClientMock.WatchFilesCalls()[1].Watchers, func(w *lsproto.FileSystemWatcher) bool {
601-
return *w.GlobPattern.Pattern == "/home/projects/TS/p1/src/z.ts" && *w.Kind == lsproto.WatchKindCreate
601+
return *w.GlobPattern.Pattern == "/home/projects/TS/p1/src/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,json}" && *w.Kind == lsproto.WatchKindCreate
602602
}))
603603

604604
// Add a new file through failed lookup watch

internal/project/watch.go

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@ package project
22

33
import (
44
"context"
5+
"fmt"
56
"slices"
7+
"strings"
8+
"time"
69

710
"github.com/microsoft/typescript-go/internal/lsp/lsproto"
11+
"github.com/microsoft/typescript-go/internal/tspath"
812
)
913

1014
const (
@@ -60,3 +64,263 @@ func (w *watchedFiles[T]) update(ctx context.Context, newData T) (updated bool,
6064
w.watcherID = watcherID
6165
return true, nil
6266
}
67+
68+
func createGlobMapper(host ProjectHost) func(data map[tspath.Path]string) []string {
69+
rootDir := host.GetCurrentDirectory()
70+
rootPath := tspath.ToPath(rootDir, "", host.FS().UseCaseSensitiveFileNames())
71+
rootPathComponents := tspath.GetPathComponents(string(rootPath), "")
72+
isRootWatchable := canWatchDirectoryOrFile(rootPathComponents)
73+
74+
return func(data map[tspath.Path]string) []string {
75+
start := time.Now()
76+
77+
// dir -> recursive
78+
globSet := make(map[string]bool)
79+
80+
for path, fileName := range data {
81+
w := getDirectoryToWatchFailedLookupLocation(
82+
fileName,
83+
path,
84+
rootDir,
85+
rootPath,
86+
rootPathComponents,
87+
isRootWatchable,
88+
rootDir,
89+
true,
90+
)
91+
if w == nil {
92+
continue
93+
}
94+
globSet[w.dir] = globSet[w.dir] || !w.nonRecursive
95+
}
96+
97+
globs := make([]string, 0, len(globSet))
98+
for dir, recursive := range globSet {
99+
if recursive {
100+
globs = append(globs, dir+"/"+recursiveFileGlobPattern)
101+
} else {
102+
globs = append(globs, dir+"/"+fileGlobPattern)
103+
}
104+
}
105+
slices.Sort(globs)
106+
107+
timeTaken := time.Since(start)
108+
host.Log(fmt.Sprintf("createGlobMapper took %s to create %d globs for %d failed lookups", timeTaken, len(globs), len(data)))
109+
return globs
110+
}
111+
}
112+
113+
type directoryOfFailedLookupWatch struct {
114+
dir string
115+
dirPath tspath.Path
116+
nonRecursive bool
117+
packageDir *string
118+
packageDirPath *tspath.Path
119+
}
120+
121+
func getDirectoryToWatchFailedLookupLocation(
122+
failedLookupLocation string,
123+
failedLookupLocationPath tspath.Path,
124+
rootDir string,
125+
rootPath tspath.Path,
126+
rootPathComponents []string,
127+
isRootWatchable bool,
128+
currentDirectory string,
129+
preferNonRecursiveWatch bool,
130+
) *directoryOfFailedLookupWatch {
131+
failedLookupPathComponents := tspath.GetPathComponents(string(failedLookupLocationPath), "")
132+
// Ensure failed look up is normalized path
133+
// !!! needed?
134+
if tspath.IsRootedDiskPath(failedLookupLocation) {
135+
failedLookupLocation = tspath.NormalizePath(failedLookupLocation)
136+
} else {
137+
failedLookupLocation = tspath.GetNormalizedAbsolutePath(failedLookupLocation, currentDirectory)
138+
}
139+
failedLookupComponents := tspath.GetPathComponents(failedLookupLocation, "")
140+
perceivedOsRootLength := perceivedOsRootLengthForWatching(failedLookupPathComponents, len(failedLookupPathComponents))
141+
if len(failedLookupPathComponents) <= perceivedOsRootLength+1 {
142+
return nil
143+
}
144+
// If directory path contains node module, get the most parent node_modules directory for watching
145+
nodeModulesIndex := slices.Index(failedLookupPathComponents, "node_modules")
146+
if nodeModulesIndex != -1 && nodeModulesIndex+1 <= perceivedOsRootLength+1 {
147+
return nil
148+
}
149+
lastNodeModulesIndex := lastIndex(failedLookupPathComponents, "node_modules")
150+
if isRootWatchable && isInDirectoryPath(rootPathComponents, failedLookupPathComponents) {
151+
if len(failedLookupPathComponents) > len(rootPathComponents)+1 {
152+
// Instead of watching root, watch directory in root to avoid watching excluded directories not needed for module resolution
153+
return getDirectoryOfFailedLookupWatch(
154+
failedLookupComponents,
155+
failedLookupPathComponents,
156+
max(len(rootPathComponents)+1, perceivedOsRootLength+1),
157+
lastNodeModulesIndex,
158+
false,
159+
)
160+
} else {
161+
// Always watch root directory non recursively
162+
return &directoryOfFailedLookupWatch{
163+
dir: rootDir,
164+
dirPath: rootPath,
165+
nonRecursive: true,
166+
}
167+
}
168+
}
169+
170+
return getDirectoryToWatchFromFailedLookupLocationDirectory(
171+
failedLookupComponents,
172+
failedLookupPathComponents,
173+
len(failedLookupPathComponents)-1,
174+
perceivedOsRootLength,
175+
nodeModulesIndex,
176+
rootPathComponents,
177+
lastNodeModulesIndex,
178+
preferNonRecursiveWatch,
179+
)
180+
}
181+
182+
func getDirectoryToWatchFromFailedLookupLocationDirectory(
183+
dirComponents []string,
184+
dirPathComponents []string,
185+
dirPathComponentsLength int,
186+
perceivedOsRootLength int,
187+
nodeModulesIndex int,
188+
rootPathComponents []string,
189+
lastNodeModulesIndex int,
190+
preferNonRecursiveWatch bool,
191+
) *directoryOfFailedLookupWatch {
192+
// If directory path contains node module, get the most parent node_modules directory for watching
193+
if nodeModulesIndex != -1 {
194+
// If the directory is node_modules use it to watch, always watch it recursively
195+
return getDirectoryOfFailedLookupWatch(
196+
dirComponents,
197+
dirPathComponents,
198+
nodeModulesIndex+1,
199+
lastNodeModulesIndex,
200+
false,
201+
)
202+
}
203+
204+
// Use some ancestor of the root directory
205+
nonRecursive := true
206+
length := dirPathComponentsLength
207+
if !preferNonRecursiveWatch {
208+
for i := range dirPathComponentsLength {
209+
if dirPathComponents[i] != rootPathComponents[i] {
210+
nonRecursive = false
211+
length = max(i+1, perceivedOsRootLength+1)
212+
break
213+
}
214+
}
215+
}
216+
return getDirectoryOfFailedLookupWatch(
217+
dirComponents,
218+
dirPathComponents,
219+
length,
220+
lastNodeModulesIndex,
221+
nonRecursive,
222+
)
223+
}
224+
225+
func getDirectoryOfFailedLookupWatch(
226+
dirComponents []string,
227+
dirPathComponents []string,
228+
length int,
229+
lastNodeModulesIndex int,
230+
nonRecursive bool,
231+
) *directoryOfFailedLookupWatch {
232+
packageDirLength := -1
233+
if lastNodeModulesIndex != -1 && lastNodeModulesIndex+1 >= length && lastNodeModulesIndex+2 < len(dirPathComponents) {
234+
if !strings.HasPrefix(dirPathComponents[lastNodeModulesIndex+1], "@") {
235+
packageDirLength = lastNodeModulesIndex + 2
236+
} else if lastNodeModulesIndex+3 < len(dirPathComponents) {
237+
packageDirLength = lastNodeModulesIndex + 3
238+
}
239+
}
240+
var packageDir *string
241+
var packageDirPath *tspath.Path
242+
if packageDirLength != -1 {
243+
packageDir = ptrTo(tspath.GetPathFromPathComponents(dirPathComponents[:packageDirLength]))
244+
packageDirPath = ptrTo(tspath.Path(tspath.GetPathFromPathComponents(dirComponents[:packageDirLength])))
245+
}
246+
247+
return &directoryOfFailedLookupWatch{
248+
dir: tspath.GetPathFromPathComponents(dirComponents[:length]),
249+
dirPath: tspath.Path(tspath.GetPathFromPathComponents(dirPathComponents[:length])),
250+
nonRecursive: nonRecursive,
251+
packageDir: packageDir,
252+
packageDirPath: packageDirPath,
253+
}
254+
}
255+
256+
func perceivedOsRootLengthForWatching(pathComponents []string, length int) int {
257+
// Ignore "/", "c:/"
258+
if length <= 1 {
259+
return 1
260+
}
261+
indexAfterOsRoot := 1
262+
firstComponent := pathComponents[0]
263+
isDosStyle := len(firstComponent) >= 2 && tspath.IsVolumeCharacter(firstComponent[0]) && firstComponent[1] == ':'
264+
if firstComponent != "/" && !isDosStyle && isDosStyleNextPart(pathComponents[1]) {
265+
// ignore "//vda1cs4850/c$/folderAtRoot"
266+
if length == 2 {
267+
return 2
268+
}
269+
indexAfterOsRoot = 2
270+
isDosStyle = true
271+
}
272+
273+
afterOsRoot := pathComponents[indexAfterOsRoot]
274+
if isDosStyle && !strings.EqualFold(afterOsRoot, "users") {
275+
// Paths like c:/notUsers
276+
return indexAfterOsRoot
277+
}
278+
279+
if strings.EqualFold(afterOsRoot, "workspaces") {
280+
// Paths like: /workspaces as codespaces hoist the repos in /workspaces so we have to exempt these from "2" level from root rule
281+
return indexAfterOsRoot + 1
282+
}
283+
284+
// Paths like: c:/users/username or /home/username
285+
return indexAfterOsRoot + 2
286+
}
287+
288+
func canWatchDirectoryOrFile(pathComponents []string) bool {
289+
length := len(pathComponents)
290+
// Ignore "/", "c:/"
291+
// ignore "/user", "c:/users" or "c:/folderAtRoot"
292+
if length < 2 {
293+
return false
294+
}
295+
perceivedOsRootLength := perceivedOsRootLengthForWatching(pathComponents, length)
296+
return length > perceivedOsRootLength+1
297+
}
298+
299+
func isDosStyleNextPart(part string) bool {
300+
return len(part) == 2 && tspath.IsVolumeCharacter(part[0]) && part[1] == '$'
301+
}
302+
303+
func lastIndex[T comparable](s []T, v T) int {
304+
for i := len(s) - 1; i >= 0; i-- {
305+
if s[i] == v {
306+
return i
307+
}
308+
}
309+
return -1
310+
}
311+
312+
func isInDirectoryPath(dirComponents []string, fileOrDirComponents []string) bool {
313+
if len(fileOrDirComponents) < len(dirComponents) {
314+
return false
315+
}
316+
for i := range dirComponents {
317+
if dirComponents[i] != fileOrDirComponents[i] {
318+
return false
319+
}
320+
}
321+
return true
322+
}
323+
324+
func ptrTo[T any](v T) *T {
325+
return &v
326+
}

0 commit comments

Comments
 (0)