Fix case sensitivity fswatch and users (#64210)

This commit is contained in:
Jake Bailey authored and GitHub committed 2026-09-22 22:10:05 +00:00
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)
+28
View File
@@ -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
+16 -5
View File
@@ -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.
+31 -9
View File
@@ -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
}
+33 -8
View File
@@ -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
}
+48 -3
View File
@@ -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)
}
}
})
}
}
}
+186
View File
@@ -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
}
+42
View File
@@ -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
View File
@@ -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
}
+140 -2
View File
@@ -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)
@@ -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