Skip to content

Watch with way fewer globs in LSP #971

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
May 29, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions internal/project/project.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package project
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"sync"
Expand Down Expand Up @@ -156,12 +155,9 @@ func NewProject(name string, kind Kind, currentDirectory string, host ProjectHos
}
client := host.Client()
if host.IsWatchEnabled() && 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))
})
globMapper := createGlobMapper(host)
project.failedLookupsWatch = newWatchedFiles(client, lsproto.WatchKindCreate, globMapper)
project.affectingLocationsWatch = newWatchedFiles(client, lsproto.WatchKindChange|lsproto.WatchKindCreate|lsproto.WatchKindDelete, globMapper)
}
project.markAsDirty()
return project
Expand Down Expand Up @@ -270,7 +266,7 @@ func (p *Project) getRootFileWatchGlobs() []string {
result := make([]string, 0, len(globs)+1)
result = append(result, p.configFileName)
for dir, recursive := range globs {
result = append(result, fmt.Sprintf("%s/%s", dir, core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern)))
result = append(result, fmt.Sprintf("%s/%s", tspath.NormalizePath(dir), core.IfElse(recursive, recursiveFileGlobPattern, fileGlobPattern)))
}
for _, fileName := range p.parsedCommandLine.LiteralFileNames() {
result = append(result, fileName)
Expand Down
2 changes: 1 addition & 1 deletion internal/project/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,7 @@ func TestService(t *testing.T) {

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

// Add a new file through failed lookup watch
Expand Down
264 changes: 264 additions & 0 deletions internal/project/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@ package project

import (
"context"
"fmt"
"slices"
"strings"
"time"

"github.com/microsoft/typescript-go/internal/lsp/lsproto"
"github.com/microsoft/typescript-go/internal/tspath"
)

const (
Expand Down Expand Up @@ -60,3 +64,263 @@ func (w *watchedFiles[T]) update(ctx context.Context, newData T) (updated bool,
w.watcherID = watcherID
return true, nil
}

func createGlobMapper(host ProjectHost) func(data map[tspath.Path]string) []string {
rootDir := host.GetCurrentDirectory()
rootPath := tspath.ToPath(rootDir, "", host.FS().UseCaseSensitiveFileNames())
rootPathComponents := tspath.GetPathComponents(string(rootPath), "")
isRootWatchable := canWatchDirectoryOrFile(rootPathComponents)

return func(data map[tspath.Path]string) []string {
start := time.Now()

// dir -> recursive
globSet := make(map[string]bool)

for path, fileName := range data {
w := getDirectoryToWatchFailedLookupLocation(
fileName,
path,
rootDir,
rootPath,
rootPathComponents,
isRootWatchable,
rootDir,
true,
)
if w == nil {
continue
}
globSet[w.dir] = globSet[w.dir] || !w.nonRecursive
}

globs := make([]string, 0, len(globSet))
for dir, recursive := range globSet {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In strada there is code to watch "packageDir" if it exits and dir otherwise so we can handle the symlinks .. a note here to handle package symlinks later would be good

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I should have added that, yeah. Will follow up with that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was dogfooding tsgo so couldn't find all refs on it; is it just packageDir ?? dir?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

its packageDir if packageDir exists and is symlink otherwise dir. Its in createDirectoryWatcherForPackageDir in strada. Its little complicated with having to manage state but since you dont need to, it should make it easier.

if recursive {
globs = append(globs, dir+"/"+recursiveFileGlobPattern)
} else {
globs = append(globs, dir+"/"+fileGlobPattern)
}
}
slices.Sort(globs)

timeTaken := time.Since(start)
host.Log(fmt.Sprintf("createGlobMapper took %s to create %d globs for %d failed lookups", timeTaken, len(globs), len(data)))
return globs
}
}

type directoryOfFailedLookupWatch struct {
dir string
dirPath tspath.Path
nonRecursive bool
packageDir *string
packageDirPath *tspath.Path
}

func getDirectoryToWatchFailedLookupLocation(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a pure port, basically line for line. I made no attempts to make it go any faster.

failedLookupLocation string,
failedLookupLocationPath tspath.Path,
rootDir string,
rootPath tspath.Path,
rootPathComponents []string,
isRootWatchable bool,
currentDirectory string,
preferNonRecursiveWatch bool,
) *directoryOfFailedLookupWatch {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strada has baselines on what to watch : https://github.com/microsoft/TypeScript/blob/main/tests/baselines/reference/canWatch/getDirectoryToWatchFailedLookupLocationIndirDos.baseline.md like these. do we want these atleast till we have this logic and dont make this any faster.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can do that, though I'm wary of continuing to have these sorts of super large baselines.

failedLookupPathComponents := tspath.GetPathComponents(string(failedLookupLocationPath), "")
// Ensure failed look up is normalized path
// !!! needed?
if tspath.IsRootedDiskPath(failedLookupLocation) {
failedLookupLocation = tspath.NormalizePath(failedLookupLocation)
} else {
failedLookupLocation = tspath.GetNormalizedAbsolutePath(failedLookupLocation, currentDirectory)
}
failedLookupComponents := tspath.GetPathComponents(failedLookupLocation, "")
perceivedOsRootLength := perceivedOsRootLengthForWatching(failedLookupPathComponents, len(failedLookupPathComponents))
if len(failedLookupPathComponents) <= perceivedOsRootLength+1 {
return nil
}
// If directory path contains node module, get the most parent node_modules directory for watching
nodeModulesIndex := slices.Index(failedLookupPathComponents, "node_modules")
if nodeModulesIndex != -1 && nodeModulesIndex+1 <= perceivedOsRootLength+1 {
return nil
}
lastNodeModulesIndex := lastIndex(failedLookupPathComponents, "node_modules")
if isRootWatchable && isInDirectoryPath(rootPathComponents, failedLookupPathComponents) {
if len(failedLookupPathComponents) > len(rootPathComponents)+1 {
// Instead of watching root, watch directory in root to avoid watching excluded directories not needed for module resolution
return getDirectoryOfFailedLookupWatch(
failedLookupComponents,
failedLookupPathComponents,
max(len(rootPathComponents)+1, perceivedOsRootLength+1),
lastNodeModulesIndex,
false,
)
} else {
// Always watch root directory non recursively
return &directoryOfFailedLookupWatch{
dir: rootDir,
dirPath: rootPath,
nonRecursive: true,
}
}
}

return getDirectoryToWatchFromFailedLookupLocationDirectory(
failedLookupComponents,
failedLookupPathComponents,
len(failedLookupPathComponents)-1,
perceivedOsRootLength,
nodeModulesIndex,
rootPathComponents,
lastNodeModulesIndex,
preferNonRecursiveWatch,
)
}

func getDirectoryToWatchFromFailedLookupLocationDirectory(
dirComponents []string,
dirPathComponents []string,
dirPathComponentsLength int,
perceivedOsRootLength int,
nodeModulesIndex int,
rootPathComponents []string,
lastNodeModulesIndex int,
preferNonRecursiveWatch bool,
) *directoryOfFailedLookupWatch {
// If directory path contains node module, get the most parent node_modules directory for watching
if nodeModulesIndex != -1 {
// If the directory is node_modules use it to watch, always watch it recursively
return getDirectoryOfFailedLookupWatch(
dirComponents,
dirPathComponents,
nodeModulesIndex+1,
lastNodeModulesIndex,
false,
)
}

// Use some ancestor of the root directory
nonRecursive := true
length := dirPathComponentsLength
if !preferNonRecursiveWatch {
for i := range dirPathComponentsLength {
if dirPathComponents[i] != rootPathComponents[i] {
nonRecursive = false
length = max(i+1, perceivedOsRootLength+1)
break
}
}
}
return getDirectoryOfFailedLookupWatch(
dirComponents,
dirPathComponents,
length,
lastNodeModulesIndex,
nonRecursive,
)
}

func getDirectoryOfFailedLookupWatch(
dirComponents []string,
dirPathComponents []string,
length int,
lastNodeModulesIndex int,
nonRecursive bool,
) *directoryOfFailedLookupWatch {
packageDirLength := -1
if lastNodeModulesIndex != -1 && lastNodeModulesIndex+1 >= length && lastNodeModulesIndex+2 < len(dirPathComponents) {
if !strings.HasPrefix(dirPathComponents[lastNodeModulesIndex+1], "@") {
packageDirLength = lastNodeModulesIndex + 2
} else if lastNodeModulesIndex+3 < len(dirPathComponents) {
packageDirLength = lastNodeModulesIndex + 3
}
}
var packageDir *string
var packageDirPath *tspath.Path
if packageDirLength != -1 {
packageDir = ptrTo(tspath.GetPathFromPathComponents(dirPathComponents[:packageDirLength]))
packageDirPath = ptrTo(tspath.Path(tspath.GetPathFromPathComponents(dirComponents[:packageDirLength])))
}

return &directoryOfFailedLookupWatch{
dir: tspath.GetPathFromPathComponents(dirComponents[:length]),
dirPath: tspath.Path(tspath.GetPathFromPathComponents(dirPathComponents[:length])),
nonRecursive: nonRecursive,
packageDir: packageDir,
packageDirPath: packageDirPath,
}
}

func perceivedOsRootLengthForWatching(pathComponents []string, length int) int {
// Ignore "/", "c:/"
if length <= 1 {
return 1
}
indexAfterOsRoot := 1
firstComponent := pathComponents[0]
isDosStyle := len(firstComponent) >= 2 && tspath.IsVolumeCharacter(firstComponent[0]) && firstComponent[1] == ':'
if firstComponent != "/" && !isDosStyle && isDosStyleNextPart(pathComponents[1]) {
// ignore "//vda1cs4850/c$/folderAtRoot"
if length == 2 {
return 2
}
indexAfterOsRoot = 2
isDosStyle = true
}

afterOsRoot := pathComponents[indexAfterOsRoot]
if isDosStyle && !strings.EqualFold(afterOsRoot, "users") {
// Paths like c:/notUsers
return indexAfterOsRoot
}

if strings.EqualFold(afterOsRoot, "workspaces") {
// Paths like: /workspaces as codespaces hoist the repos in /workspaces so we have to exempt these from "2" level from root rule
return indexAfterOsRoot + 1
}

// Paths like: c:/users/username or /home/username
return indexAfterOsRoot + 2
}

func canWatchDirectoryOrFile(pathComponents []string) bool {
length := len(pathComponents)
// Ignore "/", "c:/"
// ignore "/user", "c:/users" or "c:/folderAtRoot"
if length < 2 {
return false
}
perceivedOsRootLength := perceivedOsRootLengthForWatching(pathComponents, length)
return length > perceivedOsRootLength+1
}

func isDosStyleNextPart(part string) bool {
return len(part) == 2 && tspath.IsVolumeCharacter(part[0]) && part[1] == '$'
}

func lastIndex[T comparable](s []T, v T) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == v {
return i
}
}
return -1
}

func isInDirectoryPath(dirComponents []string, fileOrDirComponents []string) bool {
if len(fileOrDirComponents) < len(dirComponents) {
return false
}
for i := range dirComponents {
if dirComponents[i] != fileOrDirComponents[i] {
return false
}
}
return true
}

func ptrTo[T any](v T) *T {
return &v
}