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; 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 size_t 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.string : toStringz, fromStringz; 239 import std.conv : to; 240 241 private int fd, wd; 242 private ubyte[1024 * 4] eventBuffer; // 4kb buffer for events 243 private pollfd pfd; 244 245 /// Creates an instance using the linux inotify API 246 this(string path, bool ignored1 = false, bool ignored2 = false) 247 { 248 this.path = path; 249 getEvents(); 250 } 251 252 ~this() 253 { 254 if (fd) 255 { 256 inotify_rm_watch(fd, wd); 257 close(fd); 258 } 259 } 260 261 /// Implementation using inotify 262 FileChangeEvent[] getEvents() 263 { 264 FileChangeEvent[] events; 265 if (!fd && path.exists) 266 { 267 fd = inotify_init1(IN_NONBLOCK); 268 assert(fd != -1, 269 "inotify_init1 returned invalid file descriptor. Error code " 270 ~ errno.to!string); 271 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 events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 278 assert(fcntl(fd, F_SETFD, FD_CLOEXEC) != -1, 279 "Could not set FD_CLOEXEC bit. Error code " ~ errno.to!string); 280 } 281 if (!fd) 282 return events; 283 pfd.fd = fd; 284 pfd.events = POLLIN; 285 const code = poll(&pfd, 1, 0); 286 if (code < 0) 287 throw new Exception("Failed to poll events. Error code " ~ errno.to!string); 288 else if (code == 0) 289 return events; 290 else 291 { 292 const receivedBytes = read(fd, eventBuffer.ptr, eventBuffer.length); 293 int i = 0; 294 string fromFilename; 295 uint cookie; 296 while (true) 297 { 298 auto info = cast(inotify_event*)(eventBuffer.ptr + i); 299 assert(info.wd == wd); 300 // contains \0 at the end otherwise 301 string fileName = info.name.ptr.fromStringz().idup; 302 if (cookie && (info.mask & IN_MOVED_TO) == 0) 303 { 304 events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 305 fromFilename.length = 0; 306 cookie = 0; 307 } 308 if ((info.mask & IN_CREATE) != 0) 309 events ~= FileChangeEvent(FileChangeEventType.create, fileName); 310 if ((info.mask & IN_DELETE) != 0) 311 events ~= FileChangeEvent(FileChangeEventType.remove, fileName); 312 if ((info.mask & IN_MODIFY) != 0 || (info.mask & IN_ATTRIB) != 0) 313 events ~= FileChangeEvent(FileChangeEventType.modify, fileName); 314 if ((info.mask & IN_MOVED_FROM) != 0) 315 { 316 fromFilename = fileName; 317 cookie = info.cookie; 318 } 319 if ((info.mask & IN_MOVED_TO) != 0) 320 { 321 if (info.cookie == cookie) 322 { 323 events ~= FileChangeEvent(FileChangeEventType.rename, 324 fromFilename, fileName); 325 } 326 else 327 events ~= FileChangeEvent(FileChangeEventType.create, fileName); 328 cookie = 0; 329 } 330 if ((info.mask & IN_DELETE_SELF) != 0 || (info.mask & IN_MOVE_SELF) != 0) 331 { 332 if (fd) 333 { 334 inotify_rm_watch(fd, wd); 335 close(fd); 336 fd = wd = 0; 337 } 338 events ~= FileChangeEvent(FileChangeEventType.removeSelf, "."); 339 } 340 i += inotify_event.sizeof + info.len; 341 if (i >= receivedBytes || (cast(inotify_event*)(eventBuffer.ptr + i)).wd != wd) 342 break; 343 } 344 if (cookie) 345 { 346 events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 347 fromFilename.length = 0; 348 cookie = 0; 349 } 350 } 351 return events; 352 } 353 } 354 else version (FSWUsesPolling) 355 { 356 import std.datetime : SysTime; 357 import std.algorithm : countUntil, remove; 358 import std.path : relativePath, absolutePath, baseName; 359 360 private struct FileEntryCache 361 { 362 SysTime lastModification; 363 const string name; 364 bool isDirty; 365 ulong uniqueHash; 366 } 367 368 private FileEntryCache[] cache; 369 private bool isDir, recursive, exists; 370 private SysTime timeLastModified; 371 private string cwd; 372 373 /// Generic fallback implementation using std.file.dirEntries 374 this(string path, bool recursive = false, bool treatDirAsFile = false) 375 { 376 this.path = path; 377 cwd = getcwd; 378 this.recursive = recursive; 379 isDir = !treatDirAsFile; 380 if (!isDir && recursive) 381 throw new Exception("Can't recursively check on a file"); 382 getEvents(); 383 } 384 385 /// Generic polling implementation 386 FileChangeEvent[] getEvents() 387 { 388 const nowExists = path.exists; 389 if (isDir && (!nowExists || path.isDir)) 390 { 391 FileChangeEvent[] events; 392 if (nowExists && !exists) 393 { 394 exists = true; 395 events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 396 } 397 if (!nowExists && exists) 398 { 399 exists = false; 400 return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 401 } 402 if (!nowExists) 403 return []; 404 foreach (ref e; cache) 405 e.isDirty = true; 406 DirEntry[] created; 407 foreach (file; dirEntries(path, recursive ? SpanMode.breadth : SpanMode.shallow)) 408 { 409 auto newCache = FileEntryCache(file.timeLastModified, 410 file.name, false, file.getUniqueHash); 411 bool found = false; 412 foreach (ref cacheEntry; cache) 413 { 414 if (cacheEntry.name == newCache.name) 415 { 416 if (cacheEntry.lastModification != newCache.lastModification) 417 { 418 cacheEntry.lastModification = newCache.lastModification; 419 events ~= FileChangeEvent(FileChangeEventType.modify, 420 relativePath(file.name.absolutePath(cwd), 421 path.absolutePath(cwd))); 422 } 423 cacheEntry.isDirty = false; 424 found = true; 425 break; 426 } 427 } 428 if (!found) 429 { 430 cache ~= newCache; 431 created ~= file; 432 } 433 } 434 foreach_reverse (i, ref e; cache) 435 { 436 if (e.isDirty) 437 { 438 auto idx = created.countUntil!((a, b) => a.getUniqueHash == b.uniqueHash)(e); 439 if (idx != -1) 440 { 441 events ~= FileChangeEvent(FileChangeEventType.rename, 442 relativePath(e.name.absolutePath(cwd), 443 path.absolutePath(cwd)), relativePath(created[idx].name.absolutePath(cwd), 444 path.absolutePath(cwd))); 445 created = created.remove(idx); 446 } 447 else 448 { 449 events ~= FileChangeEvent(FileChangeEventType.remove, 450 relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 451 } 452 cache = cache.remove(i); 453 } 454 } 455 foreach (ref e; created) 456 { 457 events ~= FileChangeEvent(FileChangeEventType.create, 458 relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 459 } 460 if (events.length && events[0].type == FileChangeEventType.createSelf) 461 return [events[0]]; 462 return events; 463 } 464 else 465 { 466 if (nowExists && !exists) 467 { 468 exists = true; 469 timeLastModified = path.timeLastModified; 470 return [FileChangeEvent(FileChangeEventType.createSelf, ".")]; 471 } 472 else if (!nowExists && exists) 473 { 474 exists = false; 475 return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 476 } 477 else if (nowExists) 478 { 479 const modTime = path.timeLastModified; 480 if (modTime != timeLastModified) 481 { 482 timeLastModified = modTime; 483 return [FileChangeEvent(FileChangeEventType.modify, path.baseName)]; 484 } 485 else 486 return []; 487 } 488 else 489 return []; 490 } 491 } 492 } 493 else 494 static assert(0, "No filesystem watching method?! Try setting version = FSWForcePoll;"); 495 } 496 497 /// 498 unittest 499 { 500 import core.thread; 501 502 FileChangeEvent waitForEvent(ref FileWatch watcher) 503 { 504 FileChangeEvent[] ret; 505 while ((ret = watcher.getEvents()).length == 0) 506 { 507 Thread.sleep(1.msecs); 508 } 509 return ret[0]; 510 } 511 512 if (exists("test")) 513 rmdirRecurse("test"); 514 scope (exit) 515 { 516 if (exists("test")) 517 rmdirRecurse("test"); 518 } 519 520 auto watcher = FileWatch("test", true); 521 mkdir("test"); 522 auto ev = waitForEvent(watcher); 523 assert(ev.type == FileChangeEventType.createSelf); 524 write("test/a.txt", "abc"); 525 ev = waitForEvent(watcher); 526 assert(ev.type == FileChangeEventType.create); 527 assert(ev.path == "a.txt"); 528 Thread.sleep(2000.msecs); // for polling variant 529 append("test/a.txt", "def"); 530 ev = waitForEvent(watcher); 531 assert(ev.type == FileChangeEventType.modify); 532 assert(ev.path == "a.txt"); 533 rename("test/a.txt", "test/b.txt"); 534 ev = waitForEvent(watcher); 535 assert(ev.type == FileChangeEventType.rename); 536 assert(ev.path == "a.txt"); 537 assert(ev.newPath == "b.txt"); 538 remove("test/b.txt"); 539 ev = waitForEvent(watcher); 540 assert(ev.type == FileChangeEventType.remove); 541 assert(ev.path == "b.txt"); 542 rmdirRecurse("test"); 543 ev = waitForEvent(watcher); 544 assert(ev.type == FileChangeEventType.removeSelf); 545 } 546 547 version (linux) unittest 548 { 549 import core.thread; 550 551 FileChangeEvent waitForEvent(ref FileWatch watcher) 552 { 553 FileChangeEvent[] ret; 554 while ((ret = watcher.getEvents()).length == 0) 555 { 556 Thread.sleep(1.msecs); 557 } 558 return ret[0]; 559 } 560 561 if (exists("test2")) 562 rmdirRecurse("test2"); 563 scope (exit) 564 { 565 if (exists("test2")) 566 rmdirRecurse("test2"); 567 } 568 569 auto watcher = FileWatch("test2", true); 570 mkdir("test2"); 571 auto ev = waitForEvent(watcher); 572 assert(ev.type == FileChangeEventType.createSelf); 573 write("test2/a.txt", "abc"); 574 ev = waitForEvent(watcher); 575 assert(ev.type == FileChangeEventType.create); 576 assert(ev.path == "a.txt"); 577 rename("test2/a.txt", "./testfile-a.txt"); 578 ev = waitForEvent(watcher); 579 assert(ev.type == FileChangeEventType.remove); 580 assert(ev.path == "a.txt"); 581 rename("./testfile-a.txt", "test2/b.txt"); 582 ev = waitForEvent(watcher); 583 assert(ev.type == FileChangeEventType.create); 584 assert(ev.path == "b.txt"); 585 remove("test2/b.txt"); 586 ev = waitForEvent(watcher); 587 assert(ev.type == FileChangeEventType.remove); 588 assert(ev.path == "b.txt"); 589 rmdirRecurse("test2"); 590 ev = waitForEvent(watcher); 591 assert(ev.type == FileChangeEventType.removeSelf); 592 }