mirror of
https://github.com/microsoft/TypeScript.git
synced 2026-10-28 16:36:04 +08:00
Fix case sensitivity fswatch and users (#64210)
This commit is contained in:
1 parent
fafb768713
commit
f1f5c281ef
16 files changed
+1435
-98
No files matched your search
@@ -293,14 +293,16 @@ func (wm *WatchManager) createDirWatches(updates []dirWatchUpdate) error {
|
||||
// already present in the set, or when it is contained within a recursive watch
|
||||
// directory already in the set.
|
||||
type DirWatchSet struct {
|
||||
opts tspath.ComparePathsOptions
|
||||
dirs map[string]bool
|
||||
opts tspath.ComparePathsOptions
|
||||
dirs map[string]bool
|
||||
names map[string]string
|
||||
}
|
||||
|
||||
func NewDirWatchSet(opts tspath.ComparePathsOptions) *DirWatchSet {
|
||||
return &DirWatchSet{
|
||||
opts: opts,
|
||||
dirs: make(map[string]bool),
|
||||
opts: opts,
|
||||
dirs: make(map[string]bool),
|
||||
names: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,7 +311,11 @@ func (s *DirWatchSet) canonical(dir string) string {
|
||||
}
|
||||
|
||||
func (s *DirWatchSet) Set(dir string, recursive bool) {
|
||||
original := dir
|
||||
dir = s.canonical(dir)
|
||||
if _, exists := s.names[dir]; !exists {
|
||||
s.names[dir] = original
|
||||
}
|
||||
s.dirs[dir] = s.dirs[dir] || recursive
|
||||
}
|
||||
|
||||
@@ -329,7 +335,11 @@ func (s *DirWatchSet) Covered(dir string) bool {
|
||||
}
|
||||
|
||||
func (s *DirWatchSet) Dirs() map[string]bool {
|
||||
return s.dirs
|
||||
dirs := make(map[string]bool, len(s.dirs))
|
||||
for key, recursive := range s.dirs {
|
||||
dirs[s.names[key]] = recursive
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
func (wm *WatchManager) IsPathUnderWatch(path string, opts tspath.ComparePathsOptions) bool {
|
||||
|
||||
@@ -83,8 +83,8 @@ func TestDirWatchSetCanonicalDedup(t *testing.T) {
|
||||
|
||||
dirs := insensitive.Dirs()
|
||||
assert.Equal(t, len(dirs), 1, "differently-cased dirs must collapse to one entry")
|
||||
_, canonical := dirs["/repo/node_modules/pkgname"]
|
||||
assert.Assert(t, canonical, "Dirs must be keyed by the canonicalized path")
|
||||
_, original := dirs["/repo/Node_Modules/PkgName"]
|
||||
assert.Assert(t, original, "Dirs must retain the original spelling used for registration")
|
||||
|
||||
sensitive := NewDirWatchSet(caseSensitiveOpts)
|
||||
sensitive.Set("/repo/Node_Modules/PkgName", false)
|
||||
|
||||
@@ -149,6 +149,34 @@ logical root, physical root, event-ID cutoff, and termination state, so
|
||||
late-added watches don't receive older queued events and symlinked watch roots
|
||||
continue reporting caller-visible paths.
|
||||
|
||||
### macOS path comparison
|
||||
|
||||
FSEvents and kqueue use the watched volume's case sensitivity, queried with
|
||||
`pathconf`, rather than assuming event paths have the same spelling as the
|
||||
subscription. On case-insensitive volumes, CoreFoundation case folding and NFC
|
||||
normalization recognize Unicode aliases, including expansions such as sharp s /
|
||||
`SS` and ligatures / letter sequences. This is not width- or
|
||||
diacritic-insensitive comparison.
|
||||
|
||||
Folded forms are comparison keys, never displayed or opened paths. Watch roots
|
||||
and subscribed filenames are normalized to NFC. Directory events retain the
|
||||
caller's root casing, with NFC suffixes for FSEvents and on-disk child spellings
|
||||
for kqueue; `WatchFile` events use the subscribed NFC filename. Rebasing uses
|
||||
original path boundaries rather than folded byte lengths. FSEvents routing,
|
||||
shared callback filtering, overflow matching, and logical-root deletion use the
|
||||
same comparison rules.
|
||||
|
||||
An allocation-free ASCII comparison fast path avoids native folding. Watch-root
|
||||
comparison forms are prepared at subscription time, while event paths are
|
||||
folded lazily and reused across routing comparisons and within callback
|
||||
filtering passes. `WatchFile` reuses its parent subscription's comparer rather
|
||||
than querying filesystem case sensitivity twice.
|
||||
|
||||
The native fold has been compared with aliases and distinct names on
|
||||
case-insensitive APFS, but is not a guarantee of identical lookup tables on every
|
||||
filesystem or macOS version. Case-sensitive comparison and watcher backends on
|
||||
other platforms remain unchanged.
|
||||
|
||||
## New backends
|
||||
|
||||
**fanotify** (Linux, kernel ≥ 5.13) is the default on Linux when available. It
|
||||
|
||||
@@ -90,9 +90,20 @@ if errors.Is(err, fswatch.ErrWatchTerminated) {
|
||||
- Event order within a batch is **not guaranteed**.
|
||||
- The callback runs on a library goroutine, not the caller's. Each watch's
|
||||
callback is serialized (never concurrent with itself).
|
||||
- Paths in events are absolute. **Resolve symlinks before subscribing**;
|
||||
backends report canonical paths:
|
||||
- Paths in events are absolute. Subscribing through a directory symlink follows
|
||||
its target while preserving the caller-visible root in delivered paths.
|
||||
|
||||
```go
|
||||
realDir, err := filepath.EvalSymlinks(dir)
|
||||
```
|
||||
On macOS, watch roots and subscribed filenames are normalized to NFC. On volumes
|
||||
reporting case-insensitive lookup, FSEvents and kqueue match paths using
|
||||
CoreFoundation's case-insensitive fold, including expansions such as sharp s /
|
||||
`SS` and ligatures / letter sequences. This is not width- or
|
||||
diacritic-insensitive comparison. Folded forms are only comparison keys:
|
||||
directory events retain the caller's root casing, with an NFC suffix for
|
||||
FSEvents and the on-disk child spelling for kqueue; file events use the
|
||||
subscribed NFC filename. Symlink-root subscriptions likewise retain the
|
||||
caller-visible root.
|
||||
|
||||
The fold has been compared with actual aliases and distinct names on
|
||||
case-insensitive APFS. It is not a guarantee of identical Unicode lookup
|
||||
tables on every filesystem or macOS version. Case-sensitive volumes and
|
||||
watcher backends on other platforms retain exact comparison.
|
||||
@@ -2,13 +2,35 @@
|
||||
|
||||
package fswatch
|
||||
|
||||
// canonicalizePath returns the path in the form the library uses for
|
||||
// internal bookkeeping and event delivery. On macOS, paths from FSEvents
|
||||
// arrive using whatever Unicode normalization form is stored on disk;
|
||||
// usually NFC, but sometimes NFD (e.g. files created on legacy HFS+
|
||||
// volumes or copied from systems that use NFD). APFS resolves either form
|
||||
// to the same inode, but raw string comparisons against caller-supplied
|
||||
// paths (typically NFC) silently break. Normalizing every path the
|
||||
// library ingests to NFC keeps watch keys, dirWatch lookups, WatchFile
|
||||
// filters, and event paths all in one consistent form.
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// canonicalizePath normalizes watch keys, subscribed filenames, and incoming
|
||||
// FSEvents paths to NFC. kqueue retains on-disk child spellings for its fd
|
||||
// bookkeeping and directory events; on case-insensitive volumes, the native
|
||||
// path comparer handles normalization differences when filtering WatchFile.
|
||||
func canonicalizePath(p string) string { return normalizeNFC(p) }
|
||||
|
||||
func (w *watcher) pathComparer(dir string) (pathComparer, error) {
|
||||
if w.name != "fsevents" && w.name != "kqueue" {
|
||||
return pathComparer{}, nil
|
||||
}
|
||||
c, err := PathComparerForPath(dir)
|
||||
return c.comparer, err
|
||||
}
|
||||
|
||||
// PathComparerForPath queries an existing path's volume. Errors are returned to
|
||||
// the caller; a failed query must not silently enable or disable native folding.
|
||||
func PathComparerForPath(path string) (PathComparer, error) {
|
||||
// _PC_CASE_SENSITIVE from sys/unistd.h. Query the watched volume rather
|
||||
// than assuming every volume mounted on macOS is case-insensitive.
|
||||
const pcCaseSensitive = 11
|
||||
sensitive, err := unix.Pathconf(path, pcCaseSensitive)
|
||||
if err != nil {
|
||||
return PathComparer{}, &os.PathError{Op: "pathconf", Path: path, Err: err}
|
||||
}
|
||||
return PathComparer{comparer: pathComparer{ignoreCase: sensitive == 0}}, nil
|
||||
}
|
||||
@@ -2,7 +2,23 @@
|
||||
|
||||
package fswatch
|
||||
|
||||
const nativePathFolding = false
|
||||
|
||||
func foldNativePath(string) string {
|
||||
panic("fswatch: native path folding is only available on Darwin")
|
||||
}
|
||||
|
||||
// canonicalizePath is a no-op on platforms whose watchers report paths
|
||||
// using the same bytes the caller provided. See canonicalize_darwin.go
|
||||
// for the rationale on macOS.
|
||||
func canonicalizePath(p string) string { return p }
|
||||
|
||||
func (w *watcher) pathComparer(dir string) (pathComparer, error) {
|
||||
return pathComparer{}, nil
|
||||
}
|
||||
|
||||
// PathComparerForPath returns exact comparison on platforms without native
|
||||
// Darwin watch aliases. It does not inspect the host filesystem.
|
||||
func PathComparerForPath(path string) (PathComparer, error) {
|
||||
return PathComparer{}, nil
|
||||
}
|
||||
@@ -507,6 +507,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
comparison := comparisonPath{path: path}
|
||||
|
||||
isRemoved := flag&flagItemRemoved != 0
|
||||
isRenamed := flag&flagItemRenamed != 0
|
||||
@@ -527,7 +528,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
|
||||
if watch.state.terminated.Load() {
|
||||
continue
|
||||
}
|
||||
if fseventsOverflowMatches(watch.w, path) {
|
||||
if fseventsOverflowMatchesPrepared(watch.w, &comparison) {
|
||||
watch.w.events.setError(overflow)
|
||||
touched[watch.w] = struct{}{}
|
||||
}
|
||||
@@ -551,7 +552,7 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
|
||||
continue
|
||||
}
|
||||
w := watch.w
|
||||
displayPath, ok := fseventsDisplayPath(w, rawPath)
|
||||
displayPath, ok := fseventsDisplayPathPrepared(w, &comparison)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -623,18 +624,42 @@ func fsEventsCallback(cb *streamCallback, payload *fsEventsCallbackPayload) {
|
||||
}
|
||||
|
||||
func fseventsDisplayPath(w *dirWatch, rawPath string) (string, bool) {
|
||||
if isInDirectoryOrSelf(w.physicalDir, rawPath) {
|
||||
return w.displayPath(rawPath), true
|
||||
path := comparisonPath{path: rawPath}
|
||||
return fseventsDisplayPathPrepared(w, &path)
|
||||
}
|
||||
|
||||
func fseventsDisplayPathPrepared(w *dirWatch, rawPath *comparisonPath) (string, bool) {
|
||||
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
|
||||
if path, ok := w.comparer.rebasePrepared(rawPath, physical, w.dir); ok {
|
||||
return path, true
|
||||
}
|
||||
if w.physicalDir != w.dir && isInDirectoryOrSelf(w.dir, rawPath) {
|
||||
return rawPath, true
|
||||
if w.physicalDir != w.dir {
|
||||
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
|
||||
return w.comparer.rebasePrepared(rawPath, logical, w.dir)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func fseventsOverflowMatches(w *dirWatch, rawPath string) bool {
|
||||
if isInDirectoryOrSelf(w.physicalDir, rawPath) || isInDirectoryOrSelf(rawPath, w.physicalDir) {
|
||||
path := comparisonPath{path: rawPath}
|
||||
return fseventsOverflowMatchesPrepared(w, &path)
|
||||
}
|
||||
|
||||
func fseventsOverflowMatchesPrepared(w *dirWatch, rawPath *comparisonPath) bool {
|
||||
physical := comparisonPath{path: w.physicalDir, folded: w.physicalDirFold, ready: w.physicalDirFold != ""}
|
||||
if _, ok := w.comparer.suffixPrepared(physical, rawPath); ok {
|
||||
return true
|
||||
}
|
||||
return w.physicalDir != w.dir && (isInDirectoryOrSelf(w.dir, rawPath) || isInDirectoryOrSelf(rawPath, w.dir))
|
||||
if _, ok := w.comparer.suffixPrepared(*rawPath, &physical); ok {
|
||||
return true
|
||||
}
|
||||
if w.physicalDir != w.dir {
|
||||
logical := comparisonPath{path: w.dir, folded: w.dirFold, ready: w.dirFold != ""}
|
||||
if _, ok := w.comparer.suffixPrepared(logical, rawPath); ok {
|
||||
return true
|
||||
}
|
||||
_, ok := w.comparer.suffixPrepared(*rawPath, &logical)
|
||||
return ok
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
@@ -150,13 +152,16 @@ func cfArrayGetValueAtIndex(array uintptr, index int) uintptr {
|
||||
// FSEvents reports paths using whatever bytes are stored on disk. APFS is
|
||||
// normalization-insensitive for lookups (a file created as NFD opens fine
|
||||
// under the NFC form, and vice versa) but it stores and reports the original
|
||||
// bytes. The library normalizes every path that crosses the darwin boundary
|
||||
// to Unicode NFC so that:
|
||||
// bytes. The library normalizes watch paths and incoming FSEvents paths to
|
||||
// Unicode NFC so that:
|
||||
// - WatchDirectory("/.../caf\u00e9") and WatchDirectory("/.../cafe\u0301")
|
||||
// coalesce to a single dir watch;
|
||||
// - WatchFile filters by exact-string compare in NFC always match;
|
||||
// - WatchFile filters and directory routing compare the same normalized paths;
|
||||
// - subscribers can compare event paths against their own NFC strings.
|
||||
//
|
||||
// kqueue retains on-disk child spellings; its WatchFile comparisons also use
|
||||
// the native fold below on volumes reporting case-insensitive lookup.
|
||||
//
|
||||
// All-ASCII inputs are bit-identical in NFC and NFD, so the hot path skips
|
||||
// the FFI entirely. The rare non-ASCII case round-trips through CoreFoundation
|
||||
// (UTF-8 → CFString → CFMutableString → CFStringNormalize → UTF-8) with no Go
|
||||
@@ -183,6 +188,46 @@ func cfStringNormalize(mutStr uintptr, form uintptr) {
|
||||
_, _, _ = syscall_syscall6(fse_CFStringNormalize_trampoline_addr, mutStr, form, 0, 0, 0, 0)
|
||||
}
|
||||
|
||||
//go:cgo_import_dynamic fse_CFStringFold CFStringFold "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
|
||||
|
||||
var fse_CFStringFold_trampoline_addr uintptr
|
||||
|
||||
const nativePathFolding = true
|
||||
|
||||
// foldNativePath is a comparison form, never a displayed or opened path.
|
||||
// Case folding expands sharp s and ligatures without making diacritics,
|
||||
// dotless i, circled letters, or character widths interchangeable.
|
||||
func foldNativePath(s string) string {
|
||||
if isASCII(s) {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
if !utf8.ValidString(s) || strings.IndexByte(s, 0) >= 0 {
|
||||
return ""
|
||||
}
|
||||
cstr := append([]byte(s), 0)
|
||||
src := cfStringCreate(0, unsafe.Pointer(&cstr[0]), cfStringEncodingUTF8)
|
||||
if src == 0 {
|
||||
panic("fswatch: cannot create CFString for path folding")
|
||||
}
|
||||
defer cfRelease(src)
|
||||
mut := cfStringCreateMutableCopy(0, 0, src)
|
||||
if mut == 0 {
|
||||
panic("fswatch: cannot copy CFString for path folding")
|
||||
}
|
||||
defer cfRelease(mut)
|
||||
// Normalize before folding as well: a decomposed capital I with dot
|
||||
// must have the same comparison form as precomposed dotted capital I.
|
||||
cfStringNormalize(mut, cfStringNormalizationFormC)
|
||||
const cfCompareCaseInsensitive = 1
|
||||
_, _, _ = syscall_syscall6(fse_CFStringFold_trampoline_addr, mut, cfCompareCaseInsensitive, 0, 0, 0, 0)
|
||||
cfStringNormalize(mut, cfStringNormalizationFormC)
|
||||
folded := cfStringToGo(mut)
|
||||
if folded == "" {
|
||||
panic("fswatch: cannot extract folded CFString")
|
||||
}
|
||||
return folded
|
||||
}
|
||||
|
||||
//go:cgo_import_dynamic fse_CFStringGetLength CFStringGetLength "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
|
||||
|
||||
var fse_CFStringGetLength_trampoline_addr uintptr
|
||||
|
||||
@@ -62,6 +62,12 @@ TEXT fse_CFStringNormalize_trampoline<>(SB), NOSPLIT, $0-0
|
||||
GLOBL ·fse_CFStringNormalize_trampoline_addr(SB), RODATA, $8
|
||||
DATA ·fse_CFStringNormalize_trampoline_addr(SB)/8, $fse_CFStringNormalize_trampoline<>(SB)
|
||||
|
||||
TEXT fse_CFStringFold_trampoline<>(SB), NOSPLIT, $0-0
|
||||
JMP fse_CFStringFold(SB)
|
||||
|
||||
GLOBL ·fse_CFStringFold_trampoline_addr(SB), RODATA, $8
|
||||
DATA ·fse_CFStringFold_trampoline_addr(SB)/8, $fse_CFStringFold_trampoline<>(SB)
|
||||
|
||||
TEXT fse_CFStringGetLength_trampoline<>(SB), NOSPLIT, $0-0
|
||||
JMP fse_CFStringGetLength(SB)
|
||||
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
package fswatch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These tests document a real cross-normalization failure mode on macOS:
|
||||
@@ -174,3 +177,439 @@ func TestFSEventsNFDOnDiskNFCWatchFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check identity independently of the comparer, including exclusive creation.
|
||||
// Filesystems that do not support a particular alias cannot exercise its watch.
|
||||
func requireDarwinAlias(t *testing.T, a, b string) {
|
||||
t.Helper()
|
||||
first, err := os.Stat(a)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := os.Stat(b)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
t.Skip("filesystem does not alias these spellings")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !os.SameFile(first, second) {
|
||||
t.Fatal("alternate spelling resolved to a different inode")
|
||||
}
|
||||
if err := os.Mkdir(b, 0o755); !errors.Is(err, os.ErrExist) {
|
||||
t.Fatalf("exclusive alternate creation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinWatchFileComparison(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
diskRoot, watchRoot string
|
||||
diskFile, watchFile string
|
||||
alias bool
|
||||
}{
|
||||
{"root-case", "Mixed", "mixed", "file.ts", "file.ts", true},
|
||||
{"leaf-case", "root", "root", "File.ts", "file.ts", true},
|
||||
{"sharp-s", "root", "root", "\u00df.ts", "SS.ts", true},
|
||||
{"dotted-i", "root", "root", "\u0130.ts", "i\u0307.ts", true},
|
||||
{"ligature", "root", "root", "\ufb03.ts", "ffi.ts", true},
|
||||
{"normalization", "root", "root", "cafe\u0301.ts", "caf\u00e9.ts", true},
|
||||
{"dotless-i", "root", "root", "I.ts", "\u0131.ts", false},
|
||||
{"ascii-dotted-i", "root", "root", "i.ts", "\u0130.ts", false},
|
||||
{"fullwidth", "root", "root", "\uff21.ts", "A.ts", false},
|
||||
{"sibling", "root", "root", "file.ts2", "file.ts", false},
|
||||
{"case-sensitive", "root", "root", "File.ts", "file.ts", false},
|
||||
}
|
||||
for _, impl := range []Watcher{Kqueue(), FSEvents()} {
|
||||
for _, c := range cases {
|
||||
t.Run(impl.Name()+"/"+c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, reverse := range []bool{false, true} {
|
||||
diskRoot, watchRoot := c.diskRoot, c.watchRoot
|
||||
diskName, watchName := c.diskFile, c.watchFile
|
||||
if reverse {
|
||||
diskRoot, watchRoot = watchRoot, diskRoot
|
||||
diskName, watchName = watchName, diskName
|
||||
}
|
||||
parent := newTmpDir(t)
|
||||
diskRoot, watchRoot = filepath.Join(parent, diskRoot), filepath.Join(parent, watchRoot)
|
||||
if err := os.Mkdir(diskRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireDarwinAlias(t, diskRoot, watchRoot)
|
||||
diskFile, watchFile := filepath.Join(diskRoot, diskName), filepath.Join(watchRoot, watchName)
|
||||
if err := os.WriteFile(diskFile, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.alias {
|
||||
requireDarwinAlias(t, diskFile, watchFile)
|
||||
} else {
|
||||
f, err := os.OpenFile(watchFile, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
t.Skip("filesystem aliases these spellings")
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, err := os.Stat(diskFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.Stat(watchFile)
|
||||
if err != nil || os.SameFile(a, b) {
|
||||
t.Fatalf("expected distinct inodes: %v", err)
|
||||
}
|
||||
}
|
||||
// Subscribe while the target is absent to exercise discovery
|
||||
// as well as subsequent fd-based updates and deletion.
|
||||
if err := os.Remove(diskFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir, _ := subscribeForOpts(t, watchRoot, impl)
|
||||
file, _ := subscribeFileFor(t, watchFile, impl)
|
||||
wantDir := filepath.Join(watchRoot, diskName)
|
||||
if impl == FSEvents() {
|
||||
wantDir = canonicalizePath(wantDir)
|
||||
}
|
||||
for round := range 3 {
|
||||
kind := EventUpdate
|
||||
if round == 2 {
|
||||
kind = EventDelete
|
||||
if err := os.Remove(diskFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(diskFile, make([]byte, round+1), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, dir, kind, wantDir)
|
||||
if c.alias {
|
||||
events := expectContains(t, file, kind, canonicalizePath(watchFile))
|
||||
for _, e := range events {
|
||||
if e.Path != canonicalizePath(watchFile) {
|
||||
t.Fatalf("unexpected file event spelling: %q", e.Path)
|
||||
}
|
||||
}
|
||||
} else if events := file.next(400 * time.Millisecond); len(events) != 0 {
|
||||
t.Fatalf("cross-routed distinct filename: %v", events)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativePathComparerKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := PathComparer{comparer: pathComparer{ignoreCase: true}}
|
||||
for _, pair := range [][2]string{
|
||||
{"/A/file.ts", "/a/FILE.TS"},
|
||||
{"/straße/İ.ts", "/STRASSE/i\u0307.ts"},
|
||||
{"/\ufb03/ſ.ts", "/ffi/S.ts"},
|
||||
{"/Σ/ς.ts", "/σ/σ.ts"},
|
||||
{"/cafe\u0301.ts", "/caf\u00e9.ts"},
|
||||
} {
|
||||
if c.Key(pair[0]) != c.Key(pair[1]) {
|
||||
t.Errorf("unequal keys for aliases %q", pair)
|
||||
}
|
||||
}
|
||||
for _, pair := range [][2]string{
|
||||
{"/I.ts", "/\u0131.ts"},
|
||||
{"/i.ts", "/İ.ts"},
|
||||
{"/A.ts", "/A.ts"},
|
||||
{"/file.ts2", "/file.ts"},
|
||||
} {
|
||||
if c.Key(pair[0]) == c.Key(pair[1]) {
|
||||
t.Errorf("equal keys for distinct paths %q", pair)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"/A\xff.ts", "/A\x00.ts"} {
|
||||
if got := c.Key(name); got != name {
|
||||
t.Errorf("malformed path was folded: %q => %q", name, got)
|
||||
}
|
||||
}
|
||||
if got, ok := c.Rebase("/STRASSE/new.ts", "/straße", "/original"); !ok || got != "/original/new.ts" {
|
||||
t.Fatalf("expanding fold rebased at wrong boundary: %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathComparerVolumeQueryError(t *testing.T) {
|
||||
t.Parallel()
|
||||
name := "./watcher_test.go/missing"
|
||||
_, err := PathComparerForPath(name)
|
||||
var pathErr *os.PathError
|
||||
if !errors.As(err, &pathErr) || pathErr.Op != "pathconf" || pathErr.Path != name {
|
||||
t.Fatalf("volume query did not preserve path error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsDifferentCasing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, recursive := range []bool{false, true} {
|
||||
name := "nonrecursive"
|
||||
if recursive {
|
||||
name = "recursive"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
parent := newTmpDir(t)
|
||||
diskDir := filepath.Join(parent, "MixedCase")
|
||||
watchDir := filepath.Join(parent, "mixedcase")
|
||||
if err := os.Mkdir(diskDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(watchDir); errors.Is(err, os.ErrNotExist) {
|
||||
t.Skip("requires a case-insensitive filesystem")
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var opts []WatchOption
|
||||
if recursive {
|
||||
opts = append(opts, WithRecursive())
|
||||
}
|
||||
r, _ := subscribeForOpts(t, watchDir, FSEvents(), opts...)
|
||||
file := filepath.Join(diskDir, "File.ts")
|
||||
want := filepath.Join(watchDir, "File.ts")
|
||||
if err := os.WriteFile(file, []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, r, EventUpdate, want)
|
||||
if err := os.Remove(file); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, r, EventDelete, want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsWatchFileDifferentCasing(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := newTmpDir(t)
|
||||
diskFile := filepath.Join(dir, "File.ts")
|
||||
watchFile := filepath.Join(dir, "file.ts")
|
||||
if err := os.WriteFile(diskFile, []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(watchFile); errors.Is(err, os.ErrNotExist) {
|
||||
t.Skip("requires a case-insensitive filesystem")
|
||||
} else if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, _ := subscribeFileFor(t, watchFile, FSEvents())
|
||||
if err := os.WriteFile(diskFile, []byte("export const x = 1;"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, r, EventUpdate, watchFile)
|
||||
if err := os.Remove(diskFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, r, EventDelete, watchFile)
|
||||
|
||||
missingFile := filepath.Join(dir, "missing.ts")
|
||||
missing, _ := subscribeFileFor(t, missingFile, FSEvents())
|
||||
if err := os.WriteFile(filepath.Join(dir, "Missing.ts"), []byte("export {}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, missing, EventUpdate, missingFile)
|
||||
}
|
||||
|
||||
var fseventsFoldPairs = []struct {
|
||||
name string
|
||||
a, b string
|
||||
alias bool
|
||||
}{
|
||||
{"sharp-s", "\u00df", "SS", true},
|
||||
{"capital-sharp-s", "\u1e9e", "SS", true},
|
||||
{"dotted-i", "\u0130", "i\u0307", true},
|
||||
{"ligature-ff", "\ufb00", "ff", true},
|
||||
{"ligature-ffi", "\ufb03", "ffi", true},
|
||||
{"long-s", "\u017f", "S", true},
|
||||
{"sigma", "\u03c2", "\u03a3", true},
|
||||
{"accent", "\u00e9", "E\u0301", true},
|
||||
{"dotless-i", "I", "\u0131", false},
|
||||
{"ascii-dotted-i", "i", "\u0130", false},
|
||||
{"ascii-i-dot", "I", "i\u0307", false},
|
||||
{"circled-a", "\u24d0", "a", false},
|
||||
{"fullwidth-a", "\uff21", "A", false},
|
||||
}
|
||||
|
||||
func TestNativePathFold(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, pair := range fseventsFoldPairs {
|
||||
a, b := foldNativePath(pair.a), foldNativePath(pair.b)
|
||||
if (a == b) != pair.alias {
|
||||
t.Errorf("%s: folds %q / %q, alias=%v", pair.name, a, b, pair.alias)
|
||||
}
|
||||
if a != foldNativePath(canonicalizePath(pair.a)) || b != foldNativePath(canonicalizePath(pair.b)) {
|
||||
t.Errorf("%s: normalization changed folding", pair.name)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"\u0130", "I\u0307", "i\u0307"} {
|
||||
if got := foldNativePath(input); got != "i\u0307" {
|
||||
t.Errorf("fold(%q) = %q", input, got)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"/\xff", "/\xfe", "/\u00df\x00suffix"} {
|
||||
if got := foldNativePath(input); got != "" {
|
||||
t.Errorf("invalid native path %q produced %q", input, got)
|
||||
}
|
||||
}
|
||||
c := pathComparer{ignoreCase: true}
|
||||
for _, pair := range [][2]string{{"/SS/", "/\u00df/File.ts"}, {"/\u00df/", "/SS/File.ts"}} {
|
||||
if suffix, ok := c.suffix(pair[0], pair[1]); !ok || suffix != "File.ts" {
|
||||
t.Errorf("trailing separator: got (%q, %v)", suffix, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsExpansionAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, pair := range fseventsFoldPairs {
|
||||
if !pair.alias {
|
||||
continue
|
||||
}
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, reverse := range []bool{false, true} {
|
||||
a, b := pair.a, pair.b
|
||||
if reverse {
|
||||
a, b = b, a
|
||||
}
|
||||
parent := newTmpDir(t)
|
||||
disk, root := filepath.Join(parent, a), filepath.Join(parent, b)
|
||||
if err := os.Mkdir(disk, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requireDarwinAlias(t, disk, root)
|
||||
nested := filepath.Join(disk, "Nested")
|
||||
if err := os.Mkdir(nested, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
direct, _ := subscribeForOpts(t, root, FSEvents())
|
||||
recursive, _ := subscribeFor(t, root, FSEvents())
|
||||
link := filepath.Join(parent, "Link")
|
||||
makeDirSymlink(t, root, link)
|
||||
linked, _ := subscribeFor(t, link, FSEvents())
|
||||
file, _ := subscribeFileFor(t, filepath.Join(root, b+".ts"), FSEvents())
|
||||
control, _ := subscribeFileFor(t, filepath.Join(disk, a+".ts"), FSEvents())
|
||||
diskFile := filepath.Join(disk, a+".ts")
|
||||
for round := range 3 {
|
||||
kind := EventUpdate
|
||||
if round == 2 {
|
||||
kind = EventDelete
|
||||
if err := os.Remove(diskFile); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(diskFile, []byte(strings.Repeat("x", round+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, control, kind, canonicalizePath(diskFile))
|
||||
expectContains(t, file, kind, canonicalizePath(filepath.Join(root, b+".ts")))
|
||||
expectContains(t, direct, kind, canonicalizePath(filepath.Join(root, a+".ts")))
|
||||
expectContains(t, recursive, kind, canonicalizePath(filepath.Join(root, a+".ts")))
|
||||
expectContains(t, linked, kind, canonicalizePath(filepath.Join(link, a+".ts")))
|
||||
}
|
||||
child := filepath.Join(nested, "File.ts")
|
||||
if err := os.WriteFile(child, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, recursive, EventUpdate, canonicalizePath(filepath.Join(root, "Nested", "File.ts")))
|
||||
expectContains(t, linked, EventUpdate, filepath.Join(link, "Nested", "File.ts"))
|
||||
if events := direct.next(400 * time.Millisecond); len(events) != 0 {
|
||||
t.Fatalf("nonrecursive watch received nested events: %v", events)
|
||||
}
|
||||
if err := os.Remove(child); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, recursive, EventDelete, canonicalizePath(filepath.Join(root, "Nested", "File.ts")))
|
||||
if err := os.Remove(nested); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, direct, EventDelete, canonicalizePath(filepath.Join(root, "Nested")))
|
||||
if err := os.Remove(disk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectContains(t, recursive, EventDelete, canonicalizePath(root))
|
||||
terminated := false
|
||||
deadline := time.Now().Add(direct.deadline())
|
||||
for !terminated && time.Now().Before(deadline) {
|
||||
direct.mu.Lock()
|
||||
for _, err := range direct.errs {
|
||||
terminated = terminated || errors.Is(err, ErrWatchTerminated)
|
||||
}
|
||||
direct.mu.Unlock()
|
||||
if !terminated {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if !terminated {
|
||||
t.Fatal("missing root termination")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsFoldDistinctNames(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, pair := range fseventsFoldPairs {
|
||||
if pair.alias {
|
||||
continue
|
||||
}
|
||||
t.Run(pair.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, reverse := range []bool{false, true} {
|
||||
parent := newTmpDir(t)
|
||||
roots := []string{filepath.Join(parent, pair.a), filepath.Join(parent, pair.b)}
|
||||
if reverse {
|
||||
roots[0], roots[1] = roots[1], roots[0]
|
||||
}
|
||||
recorders := make([]*recordingWatcher, 2)
|
||||
files := make([]*recordingWatcher, 2)
|
||||
for i, root := range roots {
|
||||
if err := os.Mkdir(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recorders[i], _ = subscribeFor(t, root, FSEvents())
|
||||
files[i], _ = subscribeFileFor(t, root+".ts", FSEvents())
|
||||
}
|
||||
a, err := os.Stat(roots[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.Stat(roots[1])
|
||||
if err != nil || os.SameFile(a, b) {
|
||||
t.Fatalf("expected distinct inodes: %v", err)
|
||||
}
|
||||
child := filepath.Join(roots[0], "File.ts")
|
||||
file := roots[0] + ".ts"
|
||||
for round := range 3 {
|
||||
kind := EventUpdate
|
||||
for _, path := range []string{child, file} {
|
||||
if round == 2 {
|
||||
kind = EventDelete
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err := os.WriteFile(path, []byte(strings.Repeat("x", round+1)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
expectContains(t, recorders[0], kind, canonicalizePath(child))
|
||||
expectContains(t, files[0], kind, canonicalizePath(file))
|
||||
for _, r := range []*recordingWatcher{recorders[1], files[1]} {
|
||||
if events := r.next(400 * time.Millisecond); len(events) != 0 {
|
||||
t.Fatalf("cross-routed distinct name: %v", events)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -290,3 +292,314 @@ func TestFSEventsOverflowMatchesWatch(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsCaseSensitiveRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ignoreCase := range []bool{false, true} {
|
||||
w := &dirWatch{
|
||||
dir: "/logical/root",
|
||||
physicalDir: "/physical/root",
|
||||
comparer: pathComparer{ignoreCase: ignoreCase},
|
||||
}
|
||||
for _, root := range []string{"/PHYSICAL/ROOT", "/LOGICAL/ROOT"} {
|
||||
for _, suffix := range []string{"", "/File.ts", "/Nested/File.ts"} {
|
||||
path, ok := fseventsDisplayPath(w, root+suffix)
|
||||
if ok != ignoreCase || ok && path != w.dir+suffix {
|
||||
t.Errorf("display path for %q, ignoreCase=%v: got (%q, %v)", root+suffix, ignoreCase, path, ok)
|
||||
}
|
||||
}
|
||||
if fseventsOverflowMatches(w, root+"/Nested") != ignoreCase {
|
||||
t.Errorf("overflow descendant %q, ignoreCase=%v", root, ignoreCase)
|
||||
}
|
||||
if fseventsOverflowMatches(w, filepath.Dir(root)) != ignoreCase {
|
||||
t.Errorf("overflow ancestor %q, ignoreCase=%v", root, ignoreCase)
|
||||
}
|
||||
if _, ok := fseventsDisplayPath(w, root+"2/File.ts"); ok {
|
||||
t.Errorf("matched sibling of %q, ignoreCase=%v", root, ignoreCase)
|
||||
}
|
||||
if fseventsOverflowMatches(w, root+"2") {
|
||||
t.Errorf("overflow matched sibling of %q, ignoreCase=%v", root, ignoreCase)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsConsolidatedDifferentCasing(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, recursive := range []bool{false, true} {
|
||||
dw := newDirectWatcher(t, "/parent")
|
||||
dw.comparer = pathComparer{ignoreCase: true}
|
||||
child := "/parent/child"
|
||||
var got []Event
|
||||
var gotErr error
|
||||
dw.watch(child, child, recursive, func(events []Event, err error) {
|
||||
got = append(got, events...)
|
||||
gotErr = err
|
||||
}, func(path string) bool {
|
||||
return path == child+"/Ignored.ts"
|
||||
})
|
||||
dw.events.update("/parent/CHILD/File.ts")
|
||||
dw.events.update("/parent/CHILD/Ignored.ts")
|
||||
dw.events.update("/parent/CHILD2/File.ts")
|
||||
dw.events.update("/parent/CHILD/Nested/File.ts")
|
||||
dw.triggerCallbacks()
|
||||
wantCount := 1
|
||||
if recursive {
|
||||
wantCount++
|
||||
}
|
||||
if len(got) != wantCount || gotErr != nil {
|
||||
t.Fatalf("recursive=%v: got events=%v, err=%v", recursive, got, gotErr)
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" {
|
||||
t.Fatalf("unexpected path %q", e.Path)
|
||||
}
|
||||
}
|
||||
got = nil
|
||||
if !dw.terminateCallbacksForDeletedRoot("/parent/CHILD", 1, ErrWatchTerminated) {
|
||||
t.Fatal("expected differently cased child root to terminate")
|
||||
}
|
||||
dw.events.removeWatchRootAt("/parent/CHILD", 1)
|
||||
dw.triggerCallbacks()
|
||||
if !errors.Is(gotErr, ErrWatchTerminated) || len(got) != 1 || got[0].Kind != EventDelete || got[0].Path != child {
|
||||
t.Fatalf("expected child deletion and termination: got events=%v, err=%v", got, gotErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsConsolidatedExpansion(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, pair := range fseventsFoldPairs {
|
||||
for _, reverse := range []bool{false, true} {
|
||||
a, b := pair.a, pair.b
|
||||
if reverse {
|
||||
a, b = b, a
|
||||
}
|
||||
for _, recursive := range []bool{false, true} {
|
||||
dw := newDirectWatcher(t, "/parent")
|
||||
dw.setComparer(pathComparer{ignoreCase: true})
|
||||
child, raw := "/parent/"+canonicalizePath(a), "/parent/"+canonicalizePath(b)
|
||||
var got []Event
|
||||
var gotErr error
|
||||
dw.watch(child, child, recursive, func(events []Event, err error) {
|
||||
got = append(got, events...)
|
||||
gotErr = err
|
||||
}, func(path string) bool { return path == child+"/Ignored.ts" })
|
||||
dw.events.update(raw + "/File.ts")
|
||||
dw.events.update(raw + "/Nested/File.ts")
|
||||
dw.events.update(raw + "/Ignored.ts")
|
||||
dw.events.update(raw + "2/File.ts")
|
||||
dw.triggerCallbacks()
|
||||
want := 0
|
||||
if pair.alias {
|
||||
want = 1
|
||||
if recursive {
|
||||
want++
|
||||
}
|
||||
}
|
||||
if len(got) != want || gotErr != nil {
|
||||
t.Fatalf("%s reverse=%v recursive=%v: got %v, %v", pair.name, reverse, recursive, got, gotErr)
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Path != child+"/File.ts" && e.Path != child+"/Nested/File.ts" {
|
||||
t.Fatalf("incorrect rebasing: %v", e)
|
||||
}
|
||||
}
|
||||
got = nil
|
||||
if dw.terminateCallbacksForDeletedRoot(raw, 1, ErrWatchTerminated) != pair.alias {
|
||||
t.Fatalf("%s: incorrect termination", pair.name)
|
||||
}
|
||||
dw.events.removeWatchRootAt(raw, 1)
|
||||
dw.triggerCallbacks()
|
||||
if pair.alias && (len(got) != 1 || got[0].Path != child || got[0].Kind != EventDelete || !errors.Is(gotErr, ErrWatchTerminated)) {
|
||||
t.Fatalf("%s: missing deletion/termination: %v, %v", pair.name, got, gotErr)
|
||||
}
|
||||
if !pair.alias && (len(got) != 0 || gotErr != nil) {
|
||||
t.Fatalf("%s: cross-routed deletion/termination: %v, %v", pair.name, got, gotErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsExpansionRouting(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, pair := range fseventsFoldPairs {
|
||||
for _, reverse := range []bool{false, true} {
|
||||
a, b := pair.a, pair.b
|
||||
if reverse {
|
||||
a, b = b, a
|
||||
}
|
||||
for padding := range 16 {
|
||||
root := "/physical/" + strings.Repeat("x", padding) + canonicalizePath(a)
|
||||
raw := "/PHYSICAL/" + strings.Repeat("x", padding) + canonicalizePath(b)
|
||||
for _, ignoreCase := range []bool{false, true} {
|
||||
w := &dirWatch{dir: "/logical/Caller", physicalDir: root, comparer: pathComparer{ignoreCase: ignoreCase}}
|
||||
w.setComparer(w.comparer)
|
||||
want := ignoreCase && pair.alias
|
||||
for _, suffix := range []string{"", "/File\u00df.ts", "/Nested/File.ts"} {
|
||||
got, ok := fseventsDisplayPath(w, raw+suffix)
|
||||
if ok != want || ok && got != w.dir+suffix {
|
||||
t.Fatalf("%s reverse=%v padding=%d ignoreCase=%v: got (%q, %v)", pair.name, reverse, padding, ignoreCase, got, ok)
|
||||
}
|
||||
}
|
||||
if fseventsOverflowMatches(w, raw+"/Nested") != want || fseventsOverflowMatches(w, raw) != want {
|
||||
t.Fatalf("%s: incorrect overflow routing", pair.name)
|
||||
}
|
||||
if _, ok := fseventsDisplayPath(w, raw+"2/File.ts"); ok {
|
||||
t.Fatalf("%s: matched sibling", pair.name)
|
||||
}
|
||||
w.physicalDir += "/Nested"
|
||||
w.setComparer(w.comparer)
|
||||
if fseventsOverflowMatches(w, raw) != want {
|
||||
t.Fatalf("%s: incorrect ancestor overflow routing", pair.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSEventsLazyPathFolding(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tt := range []struct {
|
||||
root, event string
|
||||
match, fold bool
|
||||
}{
|
||||
{"/root", "/root/File\u00df.ts", true, false},
|
||||
{"/root", "/other/\u00df/File.ts", false, false},
|
||||
{"/ROOT", "/root/File.ts", true, false},
|
||||
{"/SS", "/\u00df/File.ts", true, true},
|
||||
{"/\u00df", "/SS/File.ts", true, true},
|
||||
} {
|
||||
w := &dirWatch{dir: tt.root, physicalDir: tt.root}
|
||||
w.setComparer(pathComparer{ignoreCase: true})
|
||||
event := comparisonPath{path: tt.event}
|
||||
for range 10 {
|
||||
if _, ok := fseventsDisplayPathPrepared(w, &event); ok != tt.match || event.ready != tt.fold {
|
||||
t.Fatalf("root=%q event=%q: match=%v, folded=%v", tt.root, tt.event, ok, event.ready)
|
||||
}
|
||||
}
|
||||
}
|
||||
var cache comparisonCache
|
||||
for _, root := range []string{"/SS", "/ss"} {
|
||||
cb := callback{
|
||||
dir: root, physicalDir: root, comparer: pathComparer{ignoreCase: true},
|
||||
physicalComparison: pathComparer{ignoreCase: true}.prepare(root),
|
||||
}
|
||||
e := cb.mapEventCached(Event{Path: "/\u00df/File.ts", Kind: EventUpdate}, &cache)
|
||||
if e.Path != root+"/File.ts" {
|
||||
t.Fatalf("cached callback: %v", e)
|
||||
}
|
||||
}
|
||||
if len(cache) != 1 || cache["/\u00df/File.ts"] != "/ss/file.ts" {
|
||||
t.Fatalf("expected one shared event comparison, got %v", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFSEventsDisplayPath(b *testing.B) {
|
||||
const root = "/Users/developer/work/TypeScript/packages/vscode-typescript"
|
||||
for _, scenario := range []struct {
|
||||
name string
|
||||
root string
|
||||
path string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"exact-match", root, root + "/src/File.ts", root + "/src/File.ts", true},
|
||||
{"case-mismatch", strings.ToLower(root), root + "/src/File.ts", strings.ToLower(root) + "/src/File.ts", true},
|
||||
{"sibling-miss", root, "/Users/developer/work/TypeScript/packages/other-package/src/File.ts", "", false},
|
||||
{"unrelated-miss", root, "/private/tmp/other/File.ts", "", false},
|
||||
{"unicode-match", "/Users/developer/work/caf\u00e9", "/Users/developer/work/CAF\u00c9/File.ts", "/Users/developer/work/caf\u00e9/File.ts", true},
|
||||
{"unicode-length-match", "/Users/developer/work/s", "/Users/developer/work/\u017f/File.ts", "/Users/developer/work/s/File.ts", true},
|
||||
{"expanding-event", "/Users/developer/work/SS", "/Users/developer/work/\u00df/File.ts", "/Users/developer/work/SS/File.ts", true},
|
||||
{"expanding-root", "/Users/developer/work/\u00df", "/Users/developer/work/SS/File.ts", "/Users/developer/work/\u00df/File.ts", true},
|
||||
{"unicode-unrelated-miss", root, "/private/tmp/\u00df/File.ts", "", false},
|
||||
} {
|
||||
b.Run(scenario.name, func(b *testing.B) {
|
||||
w := &dirWatch{dir: scenario.root, physicalDir: scenario.root, comparer: pathComparer{ignoreCase: true}}
|
||||
w.setComparer(w.comparer)
|
||||
if got, ok := fseventsDisplayPath(w, scenario.path); got != scenario.want || ok != scenario.ok {
|
||||
b.Fatalf("got (%q, %v), want (%q, %v)", got, ok, scenario.want, scenario.ok)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
fseventsDisplayPath(w, scenario.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFSEventsRoutingFanout(b *testing.B) {
|
||||
for _, count := range []int{100, 1000} {
|
||||
watches := make([]dirWatch, count)
|
||||
for i := range watches {
|
||||
dir := fmt.Sprintf("/Users/developer/work/TypeScript/packages/package%04d", i)
|
||||
watches[i] = dirWatch{dir: dir, physicalDir: dir, comparer: pathComparer{ignoreCase: true}}
|
||||
watches[i].setComparer(watches[i].comparer)
|
||||
}
|
||||
path := watches[count-1].dir + "/src/File.ts"
|
||||
b.Run(strconv.Itoa(count), func(b *testing.B) {
|
||||
matches := 0
|
||||
for i := range watches {
|
||||
if got, ok := fseventsDisplayPath(&watches[i], path); ok {
|
||||
matches++
|
||||
if got != path {
|
||||
b.Fatalf("got %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches != 1 {
|
||||
b.Fatalf("got %d matches, want 1", matches)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
event := comparisonPath{path: path}
|
||||
for i := range watches {
|
||||
fseventsDisplayPathPrepared(&watches[i], &event)
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFSEventsUnicodeFanout(b *testing.B) {
|
||||
for _, scenario := range []struct{ name, root, event string }{
|
||||
{"simple", "S", "\u017f"},
|
||||
{"expanding-event", "SS", "\u00df"},
|
||||
{"expanding-root", "\u00df", "SS"},
|
||||
} {
|
||||
for _, count := range []int{100, 1000} {
|
||||
b.Run(scenario.name+"/"+strconv.Itoa(count), func(b *testing.B) {
|
||||
watches := make([]dirWatch, count)
|
||||
for i := range watches {
|
||||
dir := fmt.Sprintf("/Users/developer/work/%s/package%04d", scenario.root, i)
|
||||
watches[i] = dirWatch{dir: dir, physicalDir: dir}
|
||||
watches[i].setComparer(pathComparer{ignoreCase: true})
|
||||
}
|
||||
path := fmt.Sprintf("/Users/developer/work/%s/package%04d/File.ts", scenario.event, count-1)
|
||||
matches := 0
|
||||
event := comparisonPath{path: path}
|
||||
for i := range watches {
|
||||
if got, ok := fseventsDisplayPathPrepared(&watches[i], &event); ok {
|
||||
matches++
|
||||
if got != watches[i].dir+"/File.ts" {
|
||||
b.Fatalf("unexpected display path %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches != 1 || !event.ready {
|
||||
b.Fatalf("matches=%d, event folded=%v", matches, event.ready)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
event := comparisonPath{path: path}
|
||||
for i := range watches {
|
||||
fseventsDisplayPathPrepared(&watches[i], &event)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package fswatch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
type pathComparer struct {
|
||||
ignoreCase bool
|
||||
}
|
||||
|
||||
// Watch roots are prepared before publication and are immutable thereafter.
|
||||
// Event paths are local to one routing operation and folded only on demand.
|
||||
type comparisonPath struct {
|
||||
path string
|
||||
folded string
|
||||
ready bool
|
||||
cache *comparisonCache
|
||||
}
|
||||
|
||||
// Shared only within a synchronous callback/termination pass, never published
|
||||
// to a subscriber or stored on a watch.
|
||||
type comparisonCache map[string]string
|
||||
|
||||
func (c pathComparer) prepare(path string) comparisonPath {
|
||||
p := comparisonPath{path: path}
|
||||
if c.ignoreCase && nativePathFolding {
|
||||
p.fold()
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *comparisonPath) fold() string {
|
||||
if !p.ready {
|
||||
if p.cache != nil {
|
||||
if folded, ok := (*p.cache)[p.path]; ok {
|
||||
p.folded, p.ready = folded, true
|
||||
return folded
|
||||
}
|
||||
}
|
||||
p.folded = foldNativePath(p.path)
|
||||
p.ready = true
|
||||
if p.cache != nil {
|
||||
if *p.cache == nil {
|
||||
*p.cache = make(comparisonCache)
|
||||
}
|
||||
(*p.cache)[p.path] = p.folded
|
||||
}
|
||||
}
|
||||
return p.folded
|
||||
}
|
||||
|
||||
// suffix returns the part of path below root, respecting directory boundaries.
|
||||
func (c pathComparer) suffix(root, path string) (string, bool) {
|
||||
p := comparisonPath{path: path}
|
||||
return c.suffixPrepared(comparisonPath{path: root}, &p)
|
||||
}
|
||||
|
||||
func (c pathComparer) suffixPrepared(root comparisonPath, path *comparisonPath) (string, bool) {
|
||||
if isInDirectoryOrSelf(root.path, path.path) {
|
||||
return path.path[len(root.path):], true
|
||||
}
|
||||
if !c.ignoreCase || root.path == "" {
|
||||
return "", false
|
||||
}
|
||||
suffix, ok, unicode := pathSuffixASCII(root.path, path.path)
|
||||
if !unicode {
|
||||
return suffix, ok
|
||||
}
|
||||
return c.suffixUnicode(root, path)
|
||||
}
|
||||
|
||||
func (c pathComparer) suffixUnicode(root comparisonPath, path *comparisonPath) (string, bool) {
|
||||
if !nativePathFolding {
|
||||
return pathSuffixFoldUnicode(root.path, path.path)
|
||||
}
|
||||
a, b := root.fold(), path.fold()
|
||||
if a == "" || b == "" {
|
||||
// CFString cannot represent invalid UTF-8. Retain the simple-fold
|
||||
// behavior for malformed paths rather than truncating or losing bytes.
|
||||
return pathSuffixFoldUnicode(root.path, path.path)
|
||||
}
|
||||
if !isInDirectoryOrSelf(a, b) {
|
||||
return "", false
|
||||
}
|
||||
if a == b {
|
||||
return "", true
|
||||
}
|
||||
// Folding and canonical normalization preserve separators, but not byte
|
||||
// lengths. Find the matching boundary in the original event, not its fold.
|
||||
offset := 0
|
||||
separators := strings.Count(root.path, "/")
|
||||
trailingSeparator := root.path[len(root.path)-1] == '/'
|
||||
if !trailingSeparator {
|
||||
separators++
|
||||
}
|
||||
for range separators {
|
||||
i := strings.IndexByte(path.path[offset:], '/')
|
||||
if i < 0 {
|
||||
panic("fswatch: folded path lost a directory boundary")
|
||||
}
|
||||
offset += i + 1
|
||||
}
|
||||
if trailingSeparator {
|
||||
return path.path[offset:], true
|
||||
}
|
||||
return path.path[offset-1:], true
|
||||
}
|
||||
|
||||
// The third result requests Unicode comparison; an ASCII rejection must not
|
||||
// reject an expanding alias just because the other spelling is ASCII.
|
||||
func pathSuffixASCII(root, path string) (string, bool, bool) {
|
||||
i := 0
|
||||
// Skip shared prefixes a word at a time, which is common when routing an
|
||||
// event past sibling watches. String slice comparisons do not allocate.
|
||||
for i+8 <= len(root) && i+8 <= len(path) && root[i:i+8] == path[i:i+8] {
|
||||
i += 8
|
||||
}
|
||||
for ; i < len(root) && i < len(path); i++ {
|
||||
a, b := root[i], path[i]
|
||||
if a >= utf8.RuneSelf || b >= utf8.RuneSelf {
|
||||
return "", false, true
|
||||
}
|
||||
if a == b {
|
||||
continue
|
||||
}
|
||||
a |= 0x20
|
||||
b |= 0x20
|
||||
if a != b || a < 'a' || a > 'z' {
|
||||
return "", false, false
|
||||
}
|
||||
}
|
||||
if i == len(root) && (i == len(path) || path[i] == '/') {
|
||||
return path[i:], true, false
|
||||
}
|
||||
return "", false, i < len(root) && root[i] >= utf8.RuneSelf || i < len(path) && path[i] >= utf8.RuneSelf
|
||||
}
|
||||
|
||||
// Comparing the remaining components avoids assuming case-equivalent UTF-8
|
||||
// strings have the same byte length (for example, s and long s).
|
||||
func pathSuffixFoldUnicode(root, path string) (string, bool) {
|
||||
for {
|
||||
rootPart, rootRest, rootMore := strings.Cut(root, "/")
|
||||
pathPart, pathRest, pathMore := strings.Cut(path, "/")
|
||||
if !strings.EqualFold(rootPart, pathPart) {
|
||||
return "", false
|
||||
}
|
||||
if !rootMore {
|
||||
if pathMore {
|
||||
return path[len(pathPart):], true
|
||||
}
|
||||
return "", true
|
||||
}
|
||||
if !pathMore {
|
||||
return "", false
|
||||
}
|
||||
root, path = rootRest, pathRest
|
||||
}
|
||||
}
|
||||
|
||||
func (c pathComparer) contains(root, path string) bool {
|
||||
_, ok := c.suffix(root, path)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (c pathComparer) rebase(path, from, to string) (string, bool) {
|
||||
p := comparisonPath{path: path}
|
||||
return c.rebasePrepared(&p, comparisonPath{path: from}, to)
|
||||
}
|
||||
|
||||
func (c pathComparer) rebasePrepared(path *comparisonPath, from comparisonPath, to string) (string, bool) {
|
||||
if isInDirectoryOrSelf(from.path, path.path) {
|
||||
return rebasePath(path.path, from.path, to), true
|
||||
}
|
||||
if !c.ignoreCase || from.path == "" {
|
||||
return "", false
|
||||
}
|
||||
suffix, ok, unicode := pathSuffixASCII(from.path, path.path)
|
||||
if unicode {
|
||||
suffix, ok = c.suffixUnicode(from, path)
|
||||
}
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return joinPathSuffix(to, suffix), true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package fswatch
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// NativePathComparisonAvailable reports whether this platform implements the
|
||||
// native watch-name comparison. Volume sensitivity is queried separately.
|
||||
const NativePathComparisonAvailable = nativePathFolding
|
||||
|
||||
// PathComparer describes the filename equivalence of a watched volume. Its zero
|
||||
// value compares bytes exactly. It is immutable and safe to share.
|
||||
type PathComparer struct {
|
||||
comparer pathComparer
|
||||
}
|
||||
|
||||
// Key returns a watch-only comparison key, not a filesystem path or a compiler
|
||||
// identity. Native Darwin comparers use the same CoreFoundation folding as the
|
||||
// watcher. Other comparers preserve bytes, including malformed UTF-8.
|
||||
func (c PathComparer) Key(path string) string {
|
||||
if !c.comparer.ignoreCase || !nativePathFolding {
|
||||
return path
|
||||
}
|
||||
if strings.IndexByte(path, 0) >= 0 {
|
||||
return path
|
||||
}
|
||||
if folded := foldNativePath(path); folded != "" {
|
||||
return folded
|
||||
}
|
||||
// Invalid UTF-8 and NUL-containing names are opaque, not Unicode aliases.
|
||||
return path
|
||||
}
|
||||
|
||||
// Rebase replaces a matching directory prefix while preserving the spelling and
|
||||
// byte boundaries of the remaining event path.
|
||||
func (c PathComparer) Rebase(path, from, to string) (string, bool) {
|
||||
if !utf8.ValidString(path) || !utf8.ValidString(from) || strings.IndexByte(path, 0) >= 0 || strings.IndexByte(from, 0) >= 0 {
|
||||
return (pathComparer{}).rebase(path, from, to)
|
||||
}
|
||||
return c.comparer.rebase(path, from, to)
|
||||
}
|
||||
+112
-56
@@ -119,6 +119,17 @@ type WatchDirectoryRequest struct {
|
||||
type watchOptions struct {
|
||||
ignore func(path string) bool
|
||||
recursive bool
|
||||
file string
|
||||
}
|
||||
|
||||
// fileOption defers the file filter until the parent directory's comparer is
|
||||
// available, so WatchFile does not need a second filesystem query.
|
||||
type fileOption struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func (o fileOption) applyWatchOption(opts *watchOptions) {
|
||||
opts.file = o.path
|
||||
}
|
||||
|
||||
type ignoreOption struct {
|
||||
@@ -371,10 +382,10 @@ func (w *watcher) keyForDirWatch(dir string, recursive bool) string {
|
||||
return dir
|
||||
}
|
||||
|
||||
func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string) *dirWatch {
|
||||
func (w *watcher) findCoveringRecursiveWatchLocked(dir string, physicalDir string, comparer pathComparer) *dirWatch {
|
||||
var best *dirWatch
|
||||
for _, dw := range w.dirWatches {
|
||||
if !dw.recursive || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) {
|
||||
if !dw.recursive || dw.comparer != comparer || !isInDirectoryOrSelf(dw.dir, dir) || !isInDirectoryOrSelf(dw.physicalDir, physicalDir) {
|
||||
continue
|
||||
}
|
||||
if best == nil || len(dw.dir) > len(best.dir) {
|
||||
@@ -416,7 +427,7 @@ func (w *watcher) findConsolidationDirLocked(dir string, physicalDir string) str
|
||||
return ""
|
||||
}
|
||||
|
||||
func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool) *dirWatch {
|
||||
func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive bool, comparer pathComparer) (*dirWatch, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.dirWatches == nil {
|
||||
@@ -427,28 +438,35 @@ func (w *watcher) getOrCreateDirWatch(dir string, physicalDir string, recursive
|
||||
}
|
||||
|
||||
if w.canShareRecursiveDirWatches() {
|
||||
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil {
|
||||
return dw
|
||||
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil {
|
||||
return dw, nil
|
||||
}
|
||||
if consolidationDir := w.findConsolidationDirLocked(dir, physicalDir); consolidationDir != "" {
|
||||
dir = consolidationDir
|
||||
physicalDir = physicalDirFor(dir)
|
||||
recursive = true
|
||||
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir); dw != nil {
|
||||
return dw
|
||||
parentComparer, err := w.pathComparer(consolidationDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parentComparer == comparer {
|
||||
dir = consolidationDir
|
||||
physicalDir = physicalDirFor(dir)
|
||||
recursive = true
|
||||
if dw := w.findCoveringRecursiveWatchLocked(dir, physicalDir, comparer); dw != nil {
|
||||
return dw, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
key := w.keyForDirWatch(dir, recursive)
|
||||
if dw, ok := w.dirWatches[key]; ok {
|
||||
return dw
|
||||
return dw, nil
|
||||
}
|
||||
dw := newDirWatch(dir, physicalDir, w.debounce)
|
||||
dw.setComparer(comparer)
|
||||
dw.sequence = w.sequence
|
||||
dw.recursive = recursive
|
||||
w.dirWatches[key] = dw
|
||||
return dw
|
||||
return dw, nil
|
||||
}
|
||||
|
||||
func (w *watcher) removeDirWatch(dw *dirWatch) {
|
||||
@@ -524,8 +542,17 @@ func (w *watcher) WatchDirectories(requests []WatchDirectoryRequest) ([]Watch, e
|
||||
o.applyWatchOption(&sopts)
|
||||
}
|
||||
|
||||
dw := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive)
|
||||
id, _ := dw.watch(dir, physicalDir, sopts.recursive, fn, sopts.ignore)
|
||||
comparer, err := w.pathComparer(dir)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, err
|
||||
}
|
||||
dw, err := w.getOrCreateDirWatch(dir, physicalDir, sopts.recursive, comparer)
|
||||
if err != nil {
|
||||
rollback()
|
||||
return nil, err
|
||||
}
|
||||
id, _ := dw.addCallback(dir, physicalDir, sopts.recursive, fn, sopts.ignore, sopts.file)
|
||||
prepared = append(prepared, preparedWatch{dw: dw, id: id, recursive: sopts.recursive, dir: dir})
|
||||
if _, ok := seenDirWatches[dw]; !ok {
|
||||
seenDirWatches[dw] = struct{}{}
|
||||
@@ -578,25 +605,7 @@ func (w *watcher) WatchFile(path string, fn WatchCallback) (Watch, error) {
|
||||
return nil, errRootPath
|
||||
}
|
||||
|
||||
return w.WatchDirectory(dir, fileCallback(path, fn))
|
||||
}
|
||||
|
||||
// fileCallback wraps a WatchCallback so it only sees events for the
|
||||
// specific target path. Errors are always forwarded (with any matching
|
||||
// events delivered alongside) so callers don't lose overflow signals
|
||||
// just because their target wasn't in the same batch.
|
||||
func fileCallback(target string, fn WatchCallback) WatchCallback {
|
||||
return func(events []Event, err error) {
|
||||
var filtered []Event
|
||||
for _, e := range events {
|
||||
if e.Path == target {
|
||||
filtered = append(filtered, e)
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 || err != nil {
|
||||
fn(filtered, err)
|
||||
}
|
||||
}
|
||||
return w.WatchDirectory(dir, fn, fileOption{path: path})
|
||||
}
|
||||
|
||||
type watch struct {
|
||||
@@ -771,17 +780,21 @@ func (b *watcherBase) handleWatcherError(werr *dirWatchError) {
|
||||
// ----- dirWatch: per-directory watch state -------------------------
|
||||
|
||||
type callback struct {
|
||||
id uint64
|
||||
dir string
|
||||
physicalDir string
|
||||
watchDir string
|
||||
watchPhysicalDir string
|
||||
recursive bool
|
||||
fn WatchCallback
|
||||
ignore func(path string) bool
|
||||
sinceSeq uint64
|
||||
terminal error
|
||||
delivered bool
|
||||
id uint64
|
||||
dir string
|
||||
physicalDir string
|
||||
watchDir string
|
||||
watchPhysicalDir string
|
||||
recursive bool
|
||||
fn WatchCallback
|
||||
ignore func(path string) bool
|
||||
sinceSeq uint64
|
||||
terminal error
|
||||
delivered bool
|
||||
comparer pathComparer
|
||||
dirComparison comparisonPath
|
||||
physicalComparison comparisonPath
|
||||
fileComparison comparisonPath
|
||||
}
|
||||
|
||||
// dirWatchError associates an error with a specific directory watch.
|
||||
@@ -800,9 +813,12 @@ type dirWatch struct {
|
||||
dir string
|
||||
// physicalDir is the path passed to OS watcher APIs. It differs from dir
|
||||
// when dir or an ancestor is a symlink or reparse point to a directory.
|
||||
physicalDir string
|
||||
recursive bool
|
||||
events eventList
|
||||
physicalDir string
|
||||
recursive bool
|
||||
events eventList
|
||||
comparer pathComparer
|
||||
dirFold string
|
||||
physicalDirFold string
|
||||
|
||||
// state stores per-directory platform-specific bookkeeping (fsevents, windows).
|
||||
state any
|
||||
@@ -822,6 +838,16 @@ func newDirWatch(dir string, physicalDir string, db *debounce) *dirWatch {
|
||||
return dw
|
||||
}
|
||||
|
||||
func (dw *dirWatch) setComparer(comparer pathComparer) {
|
||||
dw.comparer = comparer
|
||||
dw.dirFold = comparer.prepare(dw.dir).folded
|
||||
if dw.physicalDir == dw.dir {
|
||||
dw.physicalDirFold = dw.dirFold
|
||||
} else {
|
||||
dw.physicalDirFold = comparer.prepare(dw.physicalDir).folded
|
||||
}
|
||||
}
|
||||
|
||||
// physicalDirFor returns the physical path to watch for dir. If dir, or an
|
||||
// ancestor of dir, is a symlink or reparse point, events are subscribed on its
|
||||
// realpath while callbacks still use dir.
|
||||
@@ -968,12 +994,20 @@ func (dw *dirWatch) triggerCallbacks() {
|
||||
}
|
||||
dw.mu.Unlock()
|
||||
|
||||
var comparisons comparisonCache
|
||||
for i, cb := range cbs {
|
||||
cbEvents := eventsByCallback[i]
|
||||
if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir {
|
||||
if cb.ignore != nil || !cb.recursive || cb.dir != dw.dir || cb.fileComparison.path != "" {
|
||||
filtered := make([]Event, 0, len(cbEvents))
|
||||
for _, e := range cbEvents {
|
||||
e = cb.mapEvent(e)
|
||||
e = cb.mapEventCached(e, &comparisons)
|
||||
if cb.fileComparison.path != "" {
|
||||
path := comparisonPath{path: e.Path, cache: &comparisons}
|
||||
if suffix, ok := cb.comparer.suffixPrepared(cb.fileComparison, &path); !ok || suffix != "" {
|
||||
continue
|
||||
}
|
||||
e.Path = cb.fileComparison.path
|
||||
}
|
||||
if cb.ignore != nil && cb.ignore(e.Path) {
|
||||
continue
|
||||
}
|
||||
@@ -1002,10 +1036,18 @@ func (dw *dirWatch) triggerCallbacks() {
|
||||
}
|
||||
|
||||
func (cb callback) mapEvent(e Event) Event {
|
||||
if cb.physicalDir != "" && cb.physicalDir != cb.dir {
|
||||
physicalPath := cb.eventPhysicalPath(e.Path)
|
||||
if isInDirectoryOrSelf(cb.physicalDir, physicalPath) {
|
||||
e.Path = rebasePath(physicalPath, cb.physicalDir, cb.dir)
|
||||
return cb.mapEventCached(e, nil)
|
||||
}
|
||||
|
||||
func (cb callback) mapEventCached(e Event, cache *comparisonCache) Event {
|
||||
if cb.physicalDir != "" && (cb.physicalDir != cb.dir || cb.comparer.ignoreCase) {
|
||||
physicalPath := comparisonPath{path: cb.eventPhysicalPath(e.Path), cache: cache}
|
||||
root := cb.physicalComparison
|
||||
if root.path == "" {
|
||||
root.path = cb.physicalDir
|
||||
}
|
||||
if path, ok := cb.comparer.rebasePrepared(&physicalPath, root, cb.dir); ok {
|
||||
e.Path = path
|
||||
}
|
||||
}
|
||||
return e
|
||||
@@ -1022,13 +1064,18 @@ func (dw *dirWatch) terminateCallbacksForDeletedRoot(path string, seq uint64, er
|
||||
dw.mu.Lock()
|
||||
defer dw.mu.Unlock()
|
||||
changed := false
|
||||
var comparisons comparisonCache
|
||||
deleted := comparisonPath{path: path, cache: &comparisons}
|
||||
for i := range dw.callbacks {
|
||||
cb := &dw.callbacks[i]
|
||||
if cb.delivered || cb.terminal != nil || cb.sinceSeq >= seq {
|
||||
continue
|
||||
}
|
||||
physicalPath := cb.eventPhysicalPath(path)
|
||||
if isInDirectoryOrSelf(path, cb.dir) || (cb.physicalDir != cb.dir && isInDirectoryOrSelf(physicalPath, cb.physicalDir)) {
|
||||
physicalPath := comparisonPath{path: cb.eventPhysicalPath(path), cache: &comparisons}
|
||||
dir, physical := cb.dirComparison, cb.physicalComparison
|
||||
_, logicalMatch := cb.comparer.suffixPrepared(deleted, &dir)
|
||||
_, physicalMatch := cb.comparer.suffixPrepared(physicalPath, &physical)
|
||||
if logicalMatch || physicalMatch {
|
||||
cb.terminal = err
|
||||
changed = true
|
||||
}
|
||||
@@ -1074,6 +1121,10 @@ func isDirectChild(dir, path string) bool {
|
||||
}
|
||||
|
||||
func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool) (uint64, bool) {
|
||||
return dw.addCallback(dir, physicalDir, recursive, fn, ignore, "")
|
||||
}
|
||||
|
||||
func (dw *dirWatch) addCallback(dir string, physicalDir string, recursive bool, fn WatchCallback, ignore func(path string) bool, file string) (uint64, bool) {
|
||||
dw.mu.Lock()
|
||||
defer dw.mu.Unlock()
|
||||
dw.nextCBID++
|
||||
@@ -1082,7 +1133,12 @@ func (dw *dirWatch) watch(dir string, physicalDir string, recursive bool, fn Wat
|
||||
if dw.sequence != nil {
|
||||
sinceSeq = dw.sequence()
|
||||
}
|
||||
dw.callbacks = append(dw.callbacks, callback{id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir, recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq})
|
||||
dw.callbacks = append(dw.callbacks, callback{
|
||||
id: id, dir: dir, physicalDir: physicalDir, watchDir: dw.dir, watchPhysicalDir: dw.physicalDir,
|
||||
recursive: recursive, fn: fn, ignore: ignore, sinceSeq: sinceSeq, comparer: dw.comparer,
|
||||
dirComparison: dw.comparer.prepare(dir), physicalComparison: dw.comparer.prepare(physicalDir),
|
||||
fileComparison: dw.comparer.prepare(file),
|
||||
})
|
||||
return id, true
|
||||
}
|
||||
|
||||
|
||||
@@ -575,6 +575,130 @@ func containsEvent(got []Event, typ EventKind, path string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestPathComparer(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
root string
|
||||
path string
|
||||
suffix string
|
||||
exact bool
|
||||
ignoreCase bool
|
||||
}{
|
||||
{"/root", "/root", "", true, true},
|
||||
{"/root", "/root/file.ts", "/file.ts", true, true},
|
||||
{"/root", "/ROOT", "", false, true},
|
||||
{"/root", "/ROOT/File.ts", "/File.ts", false, true},
|
||||
{"/root", "/ROOT/Nested/File.ts", "/Nested/File.ts", false, true},
|
||||
{"/root", "/ROOT2/File.ts", "", false, false},
|
||||
{"/root", "/roo", "", false, false},
|
||||
{"/root/sub", "/ROOT", "", false, false},
|
||||
{"/root", "/other/File.ts", "", false, false},
|
||||
{"/root", "/ROOTish/File.ts", "", false, false},
|
||||
{"/root/sub", "/ROOT/SUB", "", false, true},
|
||||
{"/root/sub", "/ROOT/su", "", false, false},
|
||||
{"/root/[", "/ROOT/{/File.ts", "", false, false},
|
||||
{"/root/@", "/ROOT/`/File.ts", "", false, false},
|
||||
{"/", "/File.ts", "File.ts", true, true},
|
||||
{"/", "/", "", true, true},
|
||||
{"", "", "", false, false},
|
||||
{"", "/File.ts", "", false, false},
|
||||
{"/caf\u00e9", "/CAF\u00c9/File.ts", "/File.ts", false, true},
|
||||
{"/s", "/\u017f/File.ts", "/File.ts", false, true},
|
||||
{"/\u017f", "/S/File.ts", "/File.ts", false, true},
|
||||
{"/s/sub", "/\u017f/SUB/File.ts", "/File.ts", false, true},
|
||||
{"/\u017f/sub", "/S/SUB/File.ts", "/File.ts", false, true},
|
||||
{"/s", "/\u017foo/File.ts", "", false, false},
|
||||
{"/k", "/\u212a/File.ts", "/File.ts", false, true},
|
||||
{"/\u03c3", "/\u03c2/File.ts", "/File.ts", false, true},
|
||||
{"/\u00e9", "/\u00c8/File.ts", "", false, false},
|
||||
{"/\u00df", "/SS/File.ts", "/File.ts", false, nativePathFolding},
|
||||
{"/root/s", "/ROOT/\u017f/File.ts", "/File.ts", false, true},
|
||||
{"/root/\u017f", "/ROOT/S", "", false, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
for _, ignoreCase := range []bool{false, true} {
|
||||
comparer := pathComparer{ignoreCase: ignoreCase}
|
||||
want := tt.exact
|
||||
if ignoreCase {
|
||||
want = tt.ignoreCase
|
||||
}
|
||||
suffix, ok := comparer.suffix(tt.root, tt.path)
|
||||
if ok != want || ok && suffix != tt.suffix {
|
||||
t.Errorf("suffix(%q, %q), ignoreCase=%v: got (%q, %v), want (%q, %v)", tt.root, tt.path, ignoreCase, suffix, ok, tt.suffix, want)
|
||||
}
|
||||
if comparer.contains(tt.root, tt.path) != want {
|
||||
t.Errorf("contains(%q, %q), ignoreCase=%v: want %v", tt.root, tt.path, ignoreCase, want)
|
||||
}
|
||||
for _, to := range []string{"/display", "/"} {
|
||||
rebased, ok := comparer.rebase(tt.path, tt.root, to)
|
||||
if ok != want || ok && rebased != joinPathSuffix(to, tt.suffix) {
|
||||
t.Errorf("rebase(%q, %q, %q), ignoreCase=%v: got (%q, %v)", tt.path, tt.root, to, ignoreCase, rebased, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathComparerUnicodeAlignment(t *testing.T) {
|
||||
t.Parallel()
|
||||
parts := []string{"s", "S", "\u017f", "k", "K", "\u212a", "\u03c3", "\u03c2", "\u00e9", "\u00c9", "\u00c8", "\U00010400", "\U00010428", "\xff", "\xfe", "\xc3"}
|
||||
comparer := pathComparer{ignoreCase: true}
|
||||
for padding := range 16 {
|
||||
prefix := "/" + strings.Repeat("a", padding)
|
||||
for _, a := range parts {
|
||||
for _, b := range parts {
|
||||
for _, child := range []string{"", "/child"} {
|
||||
root := prefix + a + child
|
||||
path := prefix + b + strings.ToUpper(child) + "/File.ts"
|
||||
want := strings.EqualFold(a, b)
|
||||
suffix, ok := comparer.suffix(root, path)
|
||||
if ok != want || ok && suffix != "/File.ts" {
|
||||
t.Fatalf("suffix(%q, %q): got (%q, %v), want match=%v", root, path, suffix, ok, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCallbackCaseSensitivity(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, ignoreCase := range []bool{false, true} {
|
||||
var got []Event
|
||||
dw := newDirectWatcher(t, "/root")
|
||||
dw.setComparer(pathComparer{ignoreCase: ignoreCase})
|
||||
dw.addCallback("/root", "/root", false, func(events []Event, err error) {
|
||||
got = append(got, events...)
|
||||
}, nil, "/root/file.ts")
|
||||
dw.events.update("/root/FILE.ts")
|
||||
dw.events.update("/root/other.ts")
|
||||
dw.triggerCallbacks()
|
||||
if ignoreCase {
|
||||
if len(got) != 1 || got[0].Path != "/root/file.ts" {
|
||||
t.Fatalf("case-insensitive callback: got %v", got)
|
||||
}
|
||||
} else if len(got) != 0 {
|
||||
t.Fatalf("case-sensitive callback: got %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathComparerExactKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := PathComparer{}
|
||||
for _, path := range []string{"/A/File.ts", "/straße/İ.ts", "/cafe\u0301.ts", "/bad\xff.ts"} {
|
||||
if got := c.Key(path); got != path {
|
||||
t.Fatalf("Key(%q) = %q", path, got)
|
||||
}
|
||||
}
|
||||
if _, ok := c.Rebase("/A/file.ts", "/a", "/target"); ok {
|
||||
t.Fatal("zero comparer must use exact matching")
|
||||
}
|
||||
if got, ok := c.Rebase("/a/file.ts", "/a", "/target"); !ok || got != "/target/file.ts" {
|
||||
t.Fatalf("Rebase = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebasePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -2107,9 +2231,23 @@ func TestFileCallbackForwardsErrAlongsideEvents(t *testing.T) {
|
||||
err error
|
||||
}
|
||||
var got []call
|
||||
cb := fileCallback(target, func(events []Event, err error) {
|
||||
dw := newDirectWatcher(t, "/abs/dir")
|
||||
dw.addCallback("/abs/dir", "/abs/dir", false, func(events []Event, err error) {
|
||||
got = append(got, call{events: events, err: err})
|
||||
})
|
||||
}, nil, target)
|
||||
cb := func(events []Event, err error) {
|
||||
for _, e := range events {
|
||||
if e.Kind == EventDelete {
|
||||
dw.events.remove(e.Path)
|
||||
} else {
|
||||
dw.events.update(e.Path)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
dw.events.setError(err)
|
||||
}
|
||||
dw.triggerCallbacks()
|
||||
}
|
||||
|
||||
// Plain events: only target events pass through, sibling dropped.
|
||||
cb([]Event{{Kind: EventUpdate, Path: target}, {Kind: EventUpdate, Path: other}}, nil)
|
||||
|
||||
+8
-8
@@ -133,10 +133,10 @@ export const value = myValue;
|
||||
|
||||
Watch Registrations::
|
||||
Directory watches::
|
||||
c:/home/src/tslibs/ts/lib
|
||||
c:/work/project
|
||||
c:/work/project/src (recursive)
|
||||
d:/work/deps
|
||||
C:/home/src/tslibs/TS/Lib
|
||||
C:/work/project
|
||||
C:/work/project/src (recursive)
|
||||
D:/work/deps
|
||||
tsconfig.json::
|
||||
SemanticDiagnostics::
|
||||
*refresh* C:/home/src/tslibs/TS/Lib/lib.es2025.full.d.ts
|
||||
@@ -257,10 +257,10 @@ Output::
|
||||
|
||||
Watch Registrations::
|
||||
Directory watches::
|
||||
c:/home/src/tslibs/ts/lib
|
||||
c:/work/project
|
||||
c:/work/project/src (recursive)
|
||||
d:/work/deps
|
||||
C:/home/src/tslibs/TS/Lib
|
||||
C:/work/project
|
||||
C:/work/project/src (recursive)
|
||||
D:/work/deps
|
||||
tsconfig.json::
|
||||
SemanticDiagnostics::
|
||||
*refresh* D:/work/deps/dep.d.ts
|
||||
|
||||
Reference in new issue
Block a user