1 /// 2 module fswatch; 3 4 import std.file; 5 6 import core.thread; 7 8 debug (FSWTestRun2) version = FSWForcePoll; 9 10 /// 11 enum FileChangeEventType : ubyte 12 { 13 /// Occurs when a file or folder is created. 14 create, 15 /// Occurs when a file or folder is modified. 16 modify, 17 /// Occurs when a file or folder is removed. 18 remove, 19 /// Occurs when a file or folder inside a folder is renamed. 20 rename, 21 /// Occurs when the watched path gets created. 22 createSelf, 23 /// Occurs when the watched path gets deleted. 24 removeSelf 25 } 26 27 /// Structure containing information about filesystem events. 28 struct FileChangeEvent 29 { 30 /// The type of this event. 31 FileChangeEventType type; 32 /// The path of the file of this event. Might not be set for createSelf and removeSelf. 33 string path; 34 /// The path the file got renamed to for a rename event. 35 string newPath = null; 36 } 37 38 private ulong getUniqueHash(DirEntry entry) 39 { 40 version (Windows) 41 return entry.timeCreated.stdTime ^ cast(ulong) entry.attributes; 42 else version (Posix) 43 return entry.statBuf.st_ino | (cast(ulong) entry.statBuf.st_dev << 32UL); 44 else 45 return (entry.timeLastModified.stdTime ^ ( 46 cast(ulong) entry.attributes << 32UL) ^ entry.linkAttributes) * entry.size; 47 } 48 49 version (FSWForcePoll) 50 version = FSWUsesPolling; 51 else 52 { 53 version (Windows) 54 version = FSWUsesWin32; 55 else version (linux) 56 version = FSWUsesINotify; 57 else version = FSWUsesPolling; 58 } 59 60 /// An instance of a FileWatcher 61 /// Contains different implementations (win32 api, inotify and polling using the std.file methods) 62 /// Specify `version = FSWForcePoll;` to force using std.file (is slower and more resource intensive than the other implementations) 63 struct FileWatch 64 { 65 /// Path of the file set using the constructor 66 const string path; 67 68 version (FSWUsesWin32) 69 { 70 import core.sys.windows.windows : HANDLE, OVERLAPPED, CloseHandle, 71 GetOverlappedResult, CreateFile, GetLastError, 72 ReadDirectoryChangesW, FILE_NOTIFY_INFORMATION, FILE_ACTION_ADDED, 73 FILE_ACTION_REMOVED, FILE_ACTION_MODIFIED, 74 FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME, 75 FILE_LIST_DIRECTORY, FILE_SHARE_WRITE, FILE_SHARE_READ, 76 FILE_SHARE_DELETE, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, 77 FILE_FLAG_BACKUP_SEMANTICS, FILE_NOTIFY_CHANGE_FILE_NAME, 78 FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, 79 ERROR_IO_PENDING, ERROR_IO_INCOMPLETE, DWORD; 80 import std.utf : toUTF8, toUTF16; 81 import std.path : absolutePath; 82 import std.conv : to; 83 import std.datetime : SysTime; 84 85 private HANDLE pathHandle; 86 private ubyte[1024 * 4] changeBuffer; // 4kb buffer for file changes 87 private bool isDir, exists, recursive; 88 private SysTime timeLastModified; 89 private DWORD receivedBytes; 90 private OVERLAPPED overlapObj; 91 private bool queued; 92 93 /// Creates an instance using the Win32 API 94 this(string path, bool recursive = false, bool treatDirAsFile = false) 95 { 96 this.path = absolutePath(path, getcwd); 97 this.recursive = recursive; 98 isDir = !treatDirAsFile; 99 if (!isDir && recursive) 100 throw new Exception("Can't recursively check on a file"); 101 getEvents(); 102 } 103 104 ~this() 105 { 106 CloseHandle(pathHandle); 107 } 108 109 /// Implementation using Win32 API or polling for files 110 FileChangeEvent[] getEvents() 111 { 112 if (isDir && (!path.exists || path.isDir)) 113 { 114 if (!path.exists) 115 { 116 if (pathHandle) 117 { 118 if (GetOverlappedResult(pathHandle, &overlapObj, &receivedBytes, false)) 119 { 120 } 121 queued = false; 122 CloseHandle(pathHandle); 123 pathHandle = null; 124 return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 125 } 126 return []; 127 } 128 FileChangeEvent[] events; 129 if (!pathHandle) 130 { 131 pathHandle = CreateFile((path.toUTF16 ~ cast(wchar) 0).ptr, FILE_LIST_DIRECTORY, 132 FILE_SHARE_WRITE | FILE_SHARE_READ | FILE_SHARE_DELETE, 133 null, OPEN_EXISTING, 134 FILE_FLAG_OVERLAPPED | FILE_FLAG_BACKUP_SEMANTICS, null); 135 if (!pathHandle) 136 throw new Exception("Error opening directory. Error code 0x" ~ GetLastError() 137 .to!string(16)); 138 queued = false; 139 events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 140 } 141 if (!queued) 142 { 143 if (!ReadDirectoryChangesW(pathHandle, changeBuffer.ptr, changeBuffer.length, recursive, 144 FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE, 145 &receivedBytes, &overlapObj, null)) 146 throw new Exception("Failed to queue read. Error 0x" ~ GetLastError() 147 .to!string(16)); 148 queued = true; 149 } 150 else 151 { 152 if (GetOverlappedResult(pathHandle, &overlapObj, &receivedBytes, false)) 153 { 154 int i = 0; 155 string fromFilename; 156 while (true) 157 { 158 auto info = cast(FILE_NOTIFY_INFORMATION*)(changeBuffer.ptr + i); 159 string fileName = (cast(wchar[])( 160 cast(ubyte*) info.FileName)[0 .. info.FileNameLength]) 161 .toUTF8.idup; 162 switch (info.Action) 163 { 164 case FILE_ACTION_ADDED: 165 events ~= FileChangeEvent(FileChangeEventType.create, fileName); 166 break; 167 case FILE_ACTION_REMOVED: 168 events ~= FileChangeEvent(FileChangeEventType.remove, fileName); 169 break; 170 case FILE_ACTION_MODIFIED: 171 events ~= FileChangeEvent(FileChangeEventType.modify, fileName); 172 break; 173 case FILE_ACTION_RENAMED_OLD_NAME: 174 fromFilename = fileName; 175 break; 176 case FILE_ACTION_RENAMED_NEW_NAME: 177 events ~= FileChangeEvent(FileChangeEventType.rename, 178 fromFilename, fileName); 179 break; 180 default: 181 throw new Exception( 182 "Unknown file notify action 0x" ~ info.Action.to!string( 183 16)); 184 } 185 i += info.NextEntryOffset; 186 if (info.NextEntryOffset == 0) 187 break; 188 } 189 queued = false; 190 } 191 else if (GetLastError() != ERROR_IO_PENDING 192 && GetLastError() != ERROR_IO_INCOMPLETE) 193 throw new Exception("Error receiving changes. Error code 0x" ~ GetLastError() 194 .to!string(16)); 195 } 196 return events; 197 } 198 else 199 { 200 const nowExists = path.exists; 201 if (nowExists && !exists) 202 { 203 exists = true; 204 timeLastModified = path.timeLastModified; 205 return [FileChangeEvent(FileChangeEventType.createSelf, path)]; 206 } 207 else if (!nowExists && exists) 208 { 209 exists = false; 210 return [FileChangeEvent(FileChangeEventType.removeSelf, path)]; 211 } 212 else if (nowExists) 213 { 214 const modTime = path.timeLastModified; 215 if (modTime != timeLastModified) 216 { 217 timeLastModified = modTime; 218 return [FileChangeEvent(FileChangeEventType.modify, path)]; 219 } 220 else 221 return []; 222 } 223 else 224 return []; 225 } 226 } 227 } 228 else version (FSWUsesINotify) 229 { 230 import core.sys.linux.sys.inotify : inotify_rm_watch, inotify_init1, 231 inotify_add_watch, inotify_event, IN_CREATE, IN_DELETE, 232 IN_DELETE_SELF, IN_MODIFY, IN_MOVE_SELF, IN_MOVED_FROM, IN_MOVED_TO, 233 IN_NONBLOCK, IN_ATTRIB, IN_EXCL_UNLINK; 234 import core.sys.linux.unistd : close, read; 235 import core.sys.linux.fcntl : fcntl, F_SETFD, FD_CLOEXEC; 236 import core.sys.linux.errno : errno; 237 import core.sys.posix.poll : pollfd, poll, POLLIN; 238 import std.algorithm : countUntil; 239 import std.string : toStringz, fromStringz; 240 import std.conv : to; 241 import std.path : relativePath, buildPath; 242 243 private int fd; 244 private bool recursive; 245 private ubyte[1024 * 4] eventBuffer; // 4kb buffer for events 246 private pollfd pfd; 247 private struct FDInfo { int wd; bool watched; string path; } 248 private FDInfo[] directoryMap; // map every watch descriptor to a directory 249 250 /// Creates an instance using the linux inotify API 251 this(string path, bool recursive = false, bool ignored = false) 252 { 253 this.path = path; 254 this.recursive = recursive; 255 getEvents(); 256 } 257 258 ~this() 259 { 260 if (fd) 261 { 262 foreach (ref fdinfo; directoryMap) 263 if (fdinfo.watched) 264 inotify_rm_watch(fd, fdinfo.wd); 265 close(fd); 266 } 267 } 268 269 private void addWatch(string path) 270 { 271 auto wd = inotify_add_watch(fd, path.toStringz, 272 IN_CREATE | IN_DELETE | IN_DELETE_SELF | IN_MODIFY | IN_MOVE_SELF 273 | IN_MOVED_FROM | IN_MOVED_TO | IN_ATTRIB | IN_EXCL_UNLINK); 274 assert(wd != -1, 275 "inotify_add_watch returned invalid watch descriptor. Error code " 276 ~ errno.to!string); 277 assert(fcntl(fd, F_SETFD, FD_CLOEXEC) != -1, 278 "Could not set FD_CLOEXEC bit. Error code " ~ errno.to!string); 279 directoryMap ~= FDInfo(wd, true, path); 280 } 281 282 /// Implementation using inotify 283 FileChangeEvent[] getEvents() 284 { 285 FileChangeEvent[] events; 286 if (!fd && path.exists) 287 { 288 fd = inotify_init1(IN_NONBLOCK); 289 assert(fd != -1, 290 "inotify_init1 returned invalid file descriptor. Error code " 291 ~ errno.to!string); 292 addWatch(path); 293 events ~= FileChangeEvent(FileChangeEventType.createSelf, path); 294 295 if (recursive) 296 foreach(string subPath; dirEntries(path, SpanMode.depth)) 297 { 298 addWatch(subPath); 299 events ~= FileChangeEvent(FileChangeEventType.createSelf, subPath); 300 } 301 } 302 if (!fd) 303 return events; 304 pfd.fd = fd; 305 pfd.events = POLLIN; 306 const code = poll(&pfd, 1, 0); 307 if (code < 0) 308 throw new Exception("Failed to poll events. Error code " ~ errno.to!string); 309 else if (code == 0) 310 return events; 311 else 312 { 313 const receivedBytes = read(fd, eventBuffer.ptr, eventBuffer.length); 314 int i = 0; 315 string fromFilename; 316 uint cookie; 317 while (true) 318 { 319 auto info = cast(inotify_event*)(eventBuffer.ptr + i); 320 // contains \0 at the end otherwise 321 string fileName = info.name.ptr.fromStringz().idup; 322 auto mapIndex = directoryMap.countUntil!(a => a.wd == info.wd); 323 string absoluteFileName = buildPath(directoryMap[mapIndex].path, fileName); 324 string relativeFilename = relativePath("/" ~ absoluteFileName, "/" ~ path); 325 if (cookie && (info.mask & IN_MOVED_TO) == 0) 326 { 327 events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 328 fromFilename.length = 0; 329 cookie = 0; 330 } 331 if ((info.mask & IN_CREATE) != 0) 332 { 333 if (absoluteFileName.isDir && recursive) 334 { 335 addWatch(absoluteFileName); 336 } 337 events ~= FileChangeEvent(FileChangeEventType.create, relativeFilename); 338 } 339 if ((info.mask & IN_DELETE) != 0) 340 events ~= FileChangeEvent(FileChangeEventType.remove, relativeFilename); 341 if ((info.mask & IN_MODIFY) != 0 || (info.mask & IN_ATTRIB) != 0) 342 events ~= FileChangeEvent(FileChangeEventType.modify, relativeFilename); 343 if ((info.mask & IN_MOVED_FROM) != 0) 344 { 345 fromFilename = fileName; 346 cookie = info.cookie; 347 } 348 if ((info.mask & IN_MOVED_TO) != 0) 349 { 350 if (info.cookie == cookie) 351 { 352 events ~= FileChangeEvent(FileChangeEventType.rename, 353 fromFilename, relativeFilename); 354 } 355 else 356 events ~= FileChangeEvent(FileChangeEventType.create, relativeFilename); 357 cookie = 0; 358 } 359 if ((info.mask & IN_DELETE_SELF) != 0 || (info.mask & IN_MOVE_SELF) != 0) 360 { 361 if (fd) 362 { 363 inotify_rm_watch(fd, info.wd); 364 directoryMap[mapIndex].watched = false; 365 } 366 if (directoryMap[mapIndex].path == path) 367 events ~= FileChangeEvent(FileChangeEventType.removeSelf, "."); 368 } 369 i += inotify_event.sizeof + info.len; 370 if (i >= receivedBytes) 371 break; 372 } 373 if (cookie) 374 { 375 events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 376 fromFilename.length = 0; 377 cookie = 0; 378 } 379 } 380 return events; 381 } 382 } 383 else version (FSWUsesPolling) 384 { 385 import std.datetime : SysTime; 386 import std.algorithm : countUntil, remove; 387 import std.path : relativePath, absolutePath, baseName; 388 389 private struct FileEntryCache 390 { 391 SysTime lastModification; 392 const string name; 393 bool isDirty; 394 ulong uniqueHash; 395 } 396 397 private FileEntryCache[] cache; 398 private bool isDir, recursive, exists; 399 private SysTime timeLastModified; 400 private string cwd; 401 402 /// Generic fallback implementation using std.file.dirEntries 403 this(string path, bool recursive = false, bool treatDirAsFile = false) 404 { 405 this.path = path; 406 cwd = getcwd; 407 this.recursive = recursive; 408 isDir = !treatDirAsFile; 409 if (!isDir && recursive) 410 throw new Exception("Can't recursively check on a file"); 411 getEvents(); 412 } 413 414 /// Generic polling implementation 415 FileChangeEvent[] getEvents() 416 { 417 const nowExists = path.exists; 418 if (isDir && (!nowExists || path.isDir)) 419 { 420 FileChangeEvent[] events; 421 if (nowExists && !exists) 422 { 423 exists = true; 424 events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 425 } 426 if (!nowExists && exists) 427 { 428 exists = false; 429 return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 430 } 431 if (!nowExists) 432 return []; 433 foreach (ref e; cache) 434 e.isDirty = true; 435 DirEntry[] created; 436 foreach (file; dirEntries(path, recursive ? SpanMode.breadth : SpanMode.shallow)) 437 { 438 auto newCache = FileEntryCache(file.timeLastModified, 439 file.name, false, file.getUniqueHash); 440 bool found = false; 441 foreach (ref cacheEntry; cache) 442 { 443 if (cacheEntry.name == newCache.name) 444 { 445 if (cacheEntry.lastModification != newCache.lastModification) 446 { 447 cacheEntry.lastModification = newCache.lastModification; 448 events ~= FileChangeEvent(FileChangeEventType.modify, 449 relativePath(file.name.absolutePath(cwd), 450 path.absolutePath(cwd))); 451 } 452 cacheEntry.isDirty = false; 453 found = true; 454 break; 455 } 456 } 457 if (!found) 458 { 459 cache ~= newCache; 460 created ~= file; 461 } 462 } 463 foreach_reverse (i, ref e; cache) 464 { 465 if (e.isDirty) 466 { 467 auto idx = created.countUntil!((a, b) => a.getUniqueHash == b.uniqueHash)(e); 468 if (idx != -1) 469 { 470 events ~= FileChangeEvent(FileChangeEventType.rename, 471 relativePath(e.name.absolutePath(cwd), 472 path.absolutePath(cwd)), relativePath(created[idx].name.absolutePath(cwd), 473 path.absolutePath(cwd))); 474 created = created.remove(idx); 475 } 476 else 477 { 478 events ~= FileChangeEvent(FileChangeEventType.remove, 479 relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 480 } 481 cache = cache.remove(i); 482 } 483 } 484 foreach (ref e; created) 485 { 486 events ~= FileChangeEvent(FileChangeEventType.create, 487 relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 488 } 489 if (events.length && events[0].type == FileChangeEventType.createSelf) 490 return [events[0]]; 491 return events; 492 } 493 else 494 { 495 if (nowExists && !exists) 496 { 497 exists = true; 498 timeLastModified = path.timeLastModified; 499 return [FileChangeEvent(FileChangeEventType.createSelf, ".")]; 500 } 501 else if (!nowExists && exists) 502 { 503 exists = false; 504 return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 505 } 506 else if (nowExists) 507 { 508 const modTime = path.timeLastModified; 509 if (modTime != timeLastModified) 510 { 511 timeLastModified = modTime; 512 return [FileChangeEvent(FileChangeEventType.modify, path.baseName)]; 513 } 514 else 515 return []; 516 } 517 else 518 return []; 519 } 520 } 521 } 522 else 523 static assert(0, "No filesystem watching method?! Try setting version = FSWForcePoll;"); 524 } 525 526 /// 527 unittest 528 { 529 import core.thread; 530 531 FileChangeEvent waitForEvent(ref FileWatch watcher) 532 { 533 FileChangeEvent[] ret; 534 while ((ret = watcher.getEvents()).length == 0) 535 { 536 Thread.sleep(1.msecs); 537 } 538 return ret[0]; 539 } 540 541 if (exists("test")) 542 rmdirRecurse("test"); 543 scope (exit) 544 { 545 if (exists("test")) 546 rmdirRecurse("test"); 547 } 548 549 auto watcher = FileWatch("test", true); 550 mkdir("test"); 551 auto ev = waitForEvent(watcher); 552 assert(ev.type == FileChangeEventType.createSelf); 553 write("test/a.txt", "abc"); 554 ev = waitForEvent(watcher); 555 assert(ev.type == FileChangeEventType.create); 556 assert(ev.path == "a.txt"); 557 Thread.sleep(2000.msecs); // for polling variant 558 append("test/a.txt", "def"); 559 ev = waitForEvent(watcher); 560 assert(ev.type == FileChangeEventType.modify); 561 assert(ev.path == "a.txt"); 562 rename("test/a.txt", "test/b.txt"); 563 ev = waitForEvent(watcher); 564 assert(ev.type == FileChangeEventType.rename); 565 assert(ev.path == "a.txt"); 566 assert(ev.newPath == "b.txt"); 567 remove("test/b.txt"); 568 ev = waitForEvent(watcher); 569 assert(ev.type == FileChangeEventType.remove); 570 assert(ev.path == "b.txt"); 571 rmdirRecurse("test"); 572 ev = waitForEvent(watcher); 573 assert(ev.type == FileChangeEventType.removeSelf); 574 } 575 576 version (linux) unittest 577 { 578 import core.thread; 579 580 FileChangeEvent waitForEvent(ref FileWatch watcher) 581 { 582 FileChangeEvent[] ret; 583 while ((ret = watcher.getEvents()).length == 0) 584 { 585 Thread.sleep(1.msecs); 586 } 587 return ret[0]; 588 } 589 590 if (exists("test2")) 591 rmdirRecurse("test2"); 592 if (exists("test3")) 593 rmdirRecurse("test3"); 594 scope (exit) 595 { 596 if (exists("test2")) 597 rmdirRecurse("test2"); 598 if (exists("test3")) 599 rmdirRecurse("test3"); 600 } 601 602 auto watcher = FileWatch("test2", true); 603 mkdir("test2"); 604 auto ev = waitForEvent(watcher); 605 assert(ev.type == FileChangeEventType.createSelf); 606 write("test2/a.txt", "abc"); 607 ev = waitForEvent(watcher); 608 assert(ev.type == FileChangeEventType.create); 609 assert(ev.path == "a.txt"); 610 rename("test2/a.txt", "./testfile-a.txt"); 611 ev = waitForEvent(watcher); 612 assert(ev.type == FileChangeEventType.remove); 613 assert(ev.path == "a.txt"); 614 rename("./testfile-a.txt", "test2/b.txt"); 615 ev = waitForEvent(watcher); 616 assert(ev.type == FileChangeEventType.create); 617 assert(ev.path == "b.txt"); 618 remove("test2/b.txt"); 619 ev = waitForEvent(watcher); 620 assert(ev.type == FileChangeEventType.remove); 621 assert(ev.path == "b.txt"); 622 623 version (FSWUsesINotify) 624 { 625 // test for creation, modification, removal of subdirectory 626 mkdir("test2/subdir"); 627 ev = waitForEvent(watcher); 628 assert(ev.type == FileChangeEventType.create); 629 assert(ev.path == "subdir"); 630 write("test2/subdir/c.txt", "abc"); 631 ev = waitForEvent(watcher); 632 assert(ev.type == FileChangeEventType.create); 633 assert(ev.path == "subdir/c.txt"); 634 write("test2/subdir/c.txt", "\nabc"); 635 ev = waitForEvent(watcher); 636 assert(ev.type == FileChangeEventType.modify); 637 assert(ev.path == "subdir/c.txt"); 638 rmdirRecurse("test2/subdir"); 639 auto events = watcher.getEvents(); 640 assert(events[0].type == FileChangeEventType.remove); 641 assert(events[0].path == "subdir/c.txt"); 642 assert(events[1].type == FileChangeEventType.remove); 643 assert(events[1].path == "subdir"); 644 } 645 // removal of watched folder 646 rmdirRecurse("test2"); 647 ev = waitForEvent(watcher); 648 assert(ev.type == FileChangeEventType.removeSelf); 649 assert(ev.path == "."); 650 651 version (FSWUsesINotify) 652 { 653 // test for a subdirectory already present 654 // both when recursive = true and recursive = false 655 foreach (recursive; [true, false]) 656 { 657 mkdir("test3"); 658 mkdir("test3/a"); 659 mkdir("test3/a/b"); 660 watcher = FileWatch("test3", recursive); 661 write("test3/a/b/c.txt", "abc"); 662 if (recursive) 663 { 664 ev = waitForEvent(watcher); 665 assert(ev.type == FileChangeEventType.create); 666 assert(ev.path == "a/b/c.txt"); 667 } 668 if (!recursive) 669 { 670 // creation of subdirectory and file within 671 // test that addWatch doesn't get called 672 mkdir("test3/d"); 673 write("test3/d/e.txt", "abc"); 674 auto revents = watcher.getEvents(); 675 assert(revents.length == 1); 676 assert(revents[0].type == FileChangeEventType.create); 677 assert(revents[0].path == "d"); 678 rmdirRecurse("test3/d"); 679 revents = watcher.getEvents(); 680 assert(revents.length == 1); 681 assert(revents[0].type == FileChangeEventType.remove); 682 assert(revents[0].path == "d"); 683 } 684 rmdirRecurse("test3"); 685 events = watcher.getEvents(); 686 if (recursive) 687 { 688 assert(events.length == 4); 689 assert(events[0].type == FileChangeEventType.remove); 690 assert(events[0].path == "a/b/c.txt"); 691 assert(events[1].type == FileChangeEventType.remove); 692 assert(events[1].path == "a/b"); 693 assert(events[2].type == FileChangeEventType.remove); 694 assert(events[2].path == "a"); 695 assert(events[3].type == FileChangeEventType.removeSelf); 696 assert(events[3].path == "."); 697 } 698 else 699 { 700 assert(events.length == 2); 701 assert(events[0].type == FileChangeEventType.remove); 702 assert(events[0].path == "a"); 703 assert(events[1].type == FileChangeEventType.removeSelf); 704 assert(events[1].path == "."); 705 } 706 } 707 } 708 }