├── .github └── workflows │ └── test.yml ├── .gitignore ├── README.md ├── dub.json ├── examples └── watch │ ├── .gitignore │ ├── dub.json │ └── source │ └── app.d ├── source └── fswatch.d ├── test.bat └── test.sh /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | dcompiler: [dmd-latest, dmd-beta, ldc-latest, ldc-beta] 10 | os: [ubuntu-latest, windows-latest, macOS-latest] 11 | step: [FSWTestRun1, FSWTestRun2] 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Install ${{ matrix.dcompiler }} 18 | uses: dlang-community/setup-dlang@v1 19 | with: 20 | compiler: ${{ matrix.dcompiler }} 21 | - name: Build 22 | run: dub build 23 | - name: Run Tests 24 | run: dub test -d ${{ matrix.step }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .dub 2 | docs.json 3 | __dummy.html 4 | *.o 5 | *.obj 6 | *.a 7 | *.exe 8 | __test__library__ 9 | dub.selections.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FSWatch 2 | 3 | [![CI](https://github.com/WebFreak001/FSWatch/actions/workflows/test.yml/badge.svg)](https://github.com/WebFreak001/FSWatch/actions/workflows/test.yml) [![Dub version](https://img.shields.io/dub/v/fswatch.svg) ![Dub downloads](https://img.shields.io/dub/dt/fswatch.svg)](https://code.dlang.org/packages/fswatch) 4 | 5 | A cross platform library simplifying watching for file changes using a non-blocking interface using the Win32 API, `inotify` or `std.file.dirEntries` as fallback. 6 | 7 | ## Comparison between implementations 8 | |Platform|Watching Directory|Watching File| 9 | |---|---|---| 10 | |Windows|`ReadDirectoryChangesW` (very fast, accurate)|polling using `std.file.timeLastModified` and `std.file.exists` (fast, not accurate for quick write)| 11 | |Linux|`inotify` (very fast, accurate)|`inotify` (very fast, accurate)| 12 | |Other|polling using `std.file.dirEntries` (slow, not accurate)|polling using `std.file.timeLastModified` and `std.file.exists` (fast, not accurate for quick write)| 13 | 14 | To force usage of the `std.file` polling implementation you can just add `version = FSWForcePoll;` 15 | 16 | Don't do this unless you have a specific reason as you will lose speed and accuracy! 17 | 18 | ## Example 19 | 20 | ```d 21 | void main() 22 | { 23 | // Initializes a FileWatch instance to watch on `project1/` (first argument) recursively (second argument) 24 | auto watcher = FileWatch("project1/", true); 25 | while (true) 26 | { 27 | // This will fetch all queued events or an empty array if there are none 28 | foreach (event; watcher.getEvents()) 29 | { 30 | // paths are relative to the watched directory in most cases 31 | if (event.path == "dub.json" || event.path == "dub.sdl") 32 | { 33 | if (event.type == FileChangeEventType.create) 34 | { 35 | // dub.json or dub.sdl got created 36 | } 37 | else if (event.type == FileChangeEventType.modify) 38 | { 39 | // dub.json or dub.sdl got modified 40 | } 41 | else if (event.type == FileChangeEventType.remove) 42 | { 43 | // dub.json or dub.sdl got deleted 44 | } 45 | } 46 | if (event.type == FileChangeEventType.rename) 47 | { 48 | // The file `event.path` has been removed to `event.newPath` 49 | } 50 | else if (event.type == FileChangeEventType.createSelf) 51 | { 52 | // the folder we are watching has been created 53 | } 54 | else if (event.type == FileChangeEventType.removeSelf) 55 | { 56 | // the folder we are watching has been deleted or renamed 57 | } 58 | } 59 | // ... 60 | myapp.handleInput(); 61 | myapp.update(); 62 | myapp.draw(); 63 | } 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /dub.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fswatch", 3 | "authors": [ 4 | "webfreak" 5 | ], 6 | "description": "a cross-platform folder & file watching library using win32, inotify or std.file", 7 | "copyright": "Copyright © 2016, webfreak", 8 | "license": "BSL-1.0", 9 | "subPackages" : ["./examples/watch"] 10 | } -------------------------------------------------------------------------------- /examples/watch/.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | -------------------------------------------------------------------------------- /examples/watch/dub.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "watch", 3 | "authors": [ 4 | "Roman Chistokhodov" 5 | ], 6 | "dependencies": { 7 | "fswatch": "*" 8 | }, 9 | "description": "FSWatch library usage example.", 10 | "copyright": "Copyright © 2016, Roman Chistokhodov", 11 | "license": "BSL-1.0", 12 | "targetPath" : "bin", 13 | "targetType" : "executable" 14 | } -------------------------------------------------------------------------------- /examples/watch/source/app.d: -------------------------------------------------------------------------------- 1 | import std.stdio; 2 | import std.format; 3 | import std.getopt; 4 | import core.thread; 5 | 6 | import fswatch; 7 | 8 | void main(string[] args) 9 | { 10 | uint period = 100; 11 | auto commandLine = getopt(args, "period", "Time period (in msecs) to check for path changes", &period); 12 | if (commandLine.helpWanted || args.length < 2) { 13 | defaultGetoptPrinter(format("Usage: %s [options...] path", args[0]), commandLine.options); 14 | return; 15 | } 16 | string toWatch = args[1]; 17 | auto watcher = FileWatch(toWatch); 18 | writefln("Watching %s", toWatch); 19 | while(true) { 20 | auto events = watcher.getEvents(); 21 | foreach(event; events) { 22 | final switch(event.type) with (FileChangeEventType) 23 | { 24 | case createSelf: 25 | writefln("Observable path created"); 26 | break; 27 | case removeSelf: 28 | writefln("Observable path deleted"); 29 | break; 30 | case create: 31 | writefln("'%s' created", event.path); 32 | break; 33 | case remove: 34 | writefln("'%s' removed", event.path); 35 | break; 36 | case rename: 37 | writefln("'%s' renamed to '%s'", event.path, event.newPath); 38 | break; 39 | case modify: 40 | writefln("'%s' contents modified", event.path); 41 | break; 42 | } 43 | } 44 | Thread.sleep(period.msecs); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /source/fswatch.d: -------------------------------------------------------------------------------- 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 | // internal path variable which shouldn't be changed because it will not update inotify/poll/win32 structures uniformly. 66 | string _path; 67 | 68 | /// Path of the file set using the constructor 69 | const ref const(string) path() return @property @safe @nogc nothrow pure 70 | { 71 | return _path; 72 | } 73 | 74 | version (FSWUsesWin32) 75 | { 76 | /* 77 | * The Windows version works by first creating an asynchronous path handle using CreateFile. 78 | * The name may suggest this creates a new file on disk, but it actually gives 79 | * a handle to basically anything I/O related. By using the flags FILE_FLAG_OVERLAPPED 80 | * and FILE_FLAG_BACKUP_SEMANTICS it can be used in ReadDirectoryChangesW. 81 | * 'Overlapped' here means asynchronous, it can also be done synchronously but that would 82 | * mean getEvents() would wait until a directory change is registered. 83 | * The asynchronous results can be received in a callback, but since FSWatch is polling 84 | * based it polls the results using GetOverlappedResult. If messages are received, 85 | * ReadDirectoryChangesW is called again. 86 | * The function will not notify when the watched directory itself is removed, so 87 | * if it doesn't exist anymore the handle is closed and set to null until it exists again. 88 | */ 89 | import core.sys.windows.basetsd : HANDLE; 90 | 91 | import core.sys.windows.winbase: OPEN_EXISTING, FILE_FLAG_OVERLAPPED, OVERLAPPED, 92 | CloseHandle, GetOverlappedResult, CreateFile, GetLastError, ReadDirectoryChangesW, 93 | FILE_FLAG_BACKUP_SEMANTICS; 94 | 95 | import core.sys.windows.winnt: FILE_NOTIFY_INFORMATION, FILE_ACTION_ADDED, 96 | FILE_ACTION_REMOVED, FILE_ACTION_MODIFIED, 97 | FILE_ACTION_RENAMED_NEW_NAME, FILE_ACTION_RENAMED_OLD_NAME, 98 | FILE_LIST_DIRECTORY, FILE_SHARE_WRITE, FILE_SHARE_READ, 99 | FILE_SHARE_DELETE, FILE_NOTIFY_CHANGE_FILE_NAME, 100 | FILE_NOTIFY_CHANGE_DIR_NAME, FILE_NOTIFY_CHANGE_LAST_WRITE, 101 | ERROR_IO_PENDING, ERROR_IO_INCOMPLETE, DWORD; 102 | import std.utf : toUTF8, toUTF16; 103 | import std.path : absolutePath; 104 | import std.conv : to; 105 | import std.datetime : SysTime; 106 | 107 | private HANDLE pathHandle; // Windows 'file' handle for ReadDirectoryChangesW 108 | private ubyte[1024 * 4] changeBuffer; // 4kb buffer for file changes 109 | private bool isDir, exists, recursive; 110 | private SysTime timeLastModified; 111 | private DWORD receivedBytes; 112 | private OVERLAPPED overlapObj; 113 | private bool queued; // Whether a directory changes watch is issued to Windows 114 | private string _absolutePath; 115 | 116 | /// Creates an instance using the Win32 API 117 | this(string path, bool recursive = false, bool treatDirAsFile = false) 118 | { 119 | _path = path; 120 | _absolutePath = absolutePath(path, getcwd); 121 | this.recursive = recursive; 122 | isDir = !treatDirAsFile; 123 | if (!isDir && recursive) 124 | throw new Exception("Can't recursively check on a file"); 125 | getEvents(); // To create a path handle and start the watch queue 126 | // The result, likely containing just 'createSelf' or 'removeSelf', is discarded 127 | // This way, the first actual call to getEvents() returns actual events 128 | } 129 | 130 | ~this() 131 | { 132 | CloseHandle(pathHandle); 133 | } 134 | 135 | private void startWatchQueue() 136 | { 137 | if (!ReadDirectoryChangesW(pathHandle, changeBuffer.ptr, changeBuffer.length, recursive, 138 | FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | FILE_NOTIFY_CHANGE_LAST_WRITE, 139 | &receivedBytes, &overlapObj, null)) 140 | throw new Exception("Failed to start directory watch queue. Error 0x" ~ GetLastError() 141 | .to!string(16)); 142 | queued = true; 143 | } 144 | 145 | /// Implementation using Win32 API or polling for files 146 | FileChangeEvent[] getEvents() 147 | { 148 | const pathExists = _absolutePath.exists; // cached so it is not called twice 149 | if (isDir && (!pathExists || _absolutePath.isDir)) 150 | { 151 | // ReadDirectoryChangesW does not report changes to the specified directory 152 | // itself, so 'removeself' is checked manually 153 | if (!pathExists) 154 | { 155 | if (pathHandle) 156 | { 157 | if (GetOverlappedResult(pathHandle, &overlapObj, &receivedBytes, false)) 158 | { 159 | } 160 | queued = false; 161 | CloseHandle(pathHandle); 162 | pathHandle = null; 163 | return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 164 | } 165 | return []; 166 | } 167 | FileChangeEvent[] events; 168 | if (!pathHandle) 169 | { 170 | pathHandle = CreateFile((_absolutePath.toUTF16 ~ cast(wchar) 0).ptr, FILE_LIST_DIRECTORY, 171 | FILE_SHARE_WRITE | FILE_SHARE_READ | FILE_SHARE_DELETE, 172 | null, OPEN_EXISTING, 173 | FILE_FLAG_OVERLAPPED | FILE_FLAG_BACKUP_SEMANTICS, null); 174 | if (!pathHandle) 175 | throw new Exception("Error opening directory. Error code 0x" ~ GetLastError() 176 | .to!string(16)); 177 | queued = false; 178 | events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 179 | } 180 | if (!queued) 181 | { 182 | startWatchQueue(); 183 | } 184 | else 185 | { 186 | // ReadDirectoryW can give double modify messages, making the queue one event behind 187 | // This sequence is repeated as a fix for now, until the intricacy of WinAPI is figured out 188 | foreach(_; 0..2) 189 | { 190 | if (GetOverlappedResult(pathHandle, &overlapObj, &receivedBytes, false)) 191 | { 192 | int i = 0; 193 | string fromFilename; 194 | while (true) 195 | { 196 | auto info = cast(FILE_NOTIFY_INFORMATION*)(changeBuffer.ptr + i); 197 | string fileName = (cast(wchar[])( 198 | cast(ubyte*) info.FileName)[0 .. info.FileNameLength]) 199 | .toUTF8.idup; 200 | switch (info.Action) 201 | { 202 | case FILE_ACTION_ADDED: 203 | events ~= FileChangeEvent(FileChangeEventType.create, fileName); 204 | break; 205 | case FILE_ACTION_REMOVED: 206 | events ~= FileChangeEvent(FileChangeEventType.remove, fileName); 207 | break; 208 | case FILE_ACTION_MODIFIED: 209 | events ~= FileChangeEvent(FileChangeEventType.modify, fileName); 210 | break; 211 | case FILE_ACTION_RENAMED_OLD_NAME: 212 | fromFilename = fileName; 213 | break; 214 | case FILE_ACTION_RENAMED_NEW_NAME: 215 | events ~= FileChangeEvent(FileChangeEventType.rename, 216 | fromFilename, fileName); 217 | break; 218 | default: 219 | throw new Exception( 220 | "Unknown file notify action 0x" ~ info.Action.to!string( 221 | 16)); 222 | } 223 | i += info.NextEntryOffset; 224 | if (info.NextEntryOffset == 0) 225 | break; 226 | } 227 | queued = false; 228 | startWatchQueue(); 229 | } 230 | else 231 | { 232 | if (GetLastError() != ERROR_IO_PENDING 233 | && GetLastError() != ERROR_IO_INCOMPLETE) 234 | throw new Exception("Error receiving changes. Error code 0x" 235 | ~ GetLastError().to!string(16)); 236 | break; 237 | } 238 | } 239 | } 240 | return events; 241 | } 242 | else 243 | { 244 | const nowExists = _absolutePath.exists; 245 | if (nowExists && !exists) 246 | { 247 | exists = true; 248 | timeLastModified = _absolutePath.timeLastModified; 249 | return [FileChangeEvent(FileChangeEventType.createSelf, _absolutePath)]; 250 | } 251 | else if (!nowExists && exists) 252 | { 253 | exists = false; 254 | return [FileChangeEvent(FileChangeEventType.removeSelf, _absolutePath)]; 255 | } 256 | else if (nowExists) 257 | { 258 | const modTime = _absolutePath.timeLastModified; 259 | if (modTime != timeLastModified) 260 | { 261 | timeLastModified = modTime; 262 | return [FileChangeEvent(FileChangeEventType.modify, _absolutePath)]; 263 | } 264 | else 265 | return []; 266 | } 267 | else 268 | return []; 269 | } 270 | } 271 | } 272 | else version (FSWUsesINotify) 273 | { 274 | import core.sys.linux.sys.inotify : inotify_rm_watch, inotify_init1, 275 | inotify_add_watch, inotify_event, IN_CREATE, IN_DELETE, 276 | IN_DELETE_SELF, IN_MODIFY, IN_MOVE_SELF, IN_MOVED_FROM, IN_MOVED_TO, 277 | IN_NONBLOCK, IN_ATTRIB, IN_EXCL_UNLINK; 278 | import core.sys.linux.unistd : close, read; 279 | import core.sys.linux.fcntl : fcntl, F_SETFD, FD_CLOEXEC, stat, stat_t, S_ISDIR; 280 | import core.sys.linux.errno : errno; 281 | import core.sys.posix.poll : pollfd, poll, POLLIN; 282 | import core.stdc.errno : ENOENT; 283 | import std.algorithm : countUntil; 284 | import std.string : toStringz, stripRight; 285 | import std.conv : to; 286 | import std.path : relativePath, buildPath; 287 | 288 | private int fd; 289 | private bool recursive; 290 | private ubyte[1024 * 4] eventBuffer; // 4kb buffer for events 291 | private pollfd pfd; 292 | private struct FDInfo { int wd; bool watched; string path; } 293 | private FDInfo[] directoryMap; // map every watch descriptor to a directory 294 | 295 | /// Creates an instance using the linux inotify API 296 | this(string path, bool recursive = false, bool ignored = false) 297 | { 298 | _path = path; 299 | this.recursive = recursive; 300 | getEvents(); 301 | } 302 | 303 | ~this() 304 | { 305 | if (fd) 306 | { 307 | foreach (ref fdinfo; directoryMap) 308 | if (fdinfo.watched) 309 | inotify_rm_watch(fd, fdinfo.wd); 310 | close(fd); 311 | } 312 | } 313 | 314 | private void addWatch(string path) 315 | { 316 | auto wd = inotify_add_watch(fd, path.toStringz, 317 | IN_CREATE | IN_DELETE | IN_DELETE_SELF | IN_MODIFY | IN_MOVE_SELF 318 | | IN_MOVED_FROM | IN_MOVED_TO | IN_ATTRIB | IN_EXCL_UNLINK); 319 | assert(wd != -1, 320 | "inotify_add_watch returned invalid watch descriptor. Error code " 321 | ~ errno.to!string); 322 | assert(fcntl(fd, F_SETFD, FD_CLOEXEC) != -1, 323 | "Could not set FD_CLOEXEC bit. Error code " ~ errno.to!string); 324 | directoryMap ~= FDInfo(wd, true, path); 325 | } 326 | 327 | /// Implementation using inotify 328 | FileChangeEvent[] getEvents() 329 | { 330 | FileChangeEvent[] events; 331 | if (!fd && path.exists) 332 | { 333 | fd = inotify_init1(IN_NONBLOCK); 334 | assert(fd != -1, 335 | "inotify_init1 returned invalid file descriptor. Error code " 336 | ~ errno.to!string); 337 | addWatch(path); 338 | events ~= FileChangeEvent(FileChangeEventType.createSelf, path); 339 | 340 | if (recursive) 341 | foreach(string subPath; dirEntries(path, SpanMode.depth)) 342 | { 343 | addWatch(subPath); 344 | events ~= FileChangeEvent(FileChangeEventType.createSelf, subPath); 345 | } 346 | } 347 | if (!fd) 348 | return events; 349 | pfd.fd = fd; 350 | pfd.events = POLLIN; 351 | const code = poll(&pfd, 1, 0); 352 | if (code < 0) 353 | throw new Exception("Failed to poll events. Error code " ~ errno.to!string); 354 | else if (code == 0) 355 | return events; 356 | else 357 | { 358 | const receivedBytes = read(fd, eventBuffer.ptr, eventBuffer.length); 359 | int i = 0; 360 | string fromFilename; 361 | uint cookie; 362 | while (true) 363 | { 364 | auto info = cast(inotify_event*)(eventBuffer.ptr + i); 365 | string fileName = info.name.ptr[0..info.len].stripRight("\0").idup; 366 | auto mapIndex = directoryMap.countUntil!(a => a.wd == info.wd); 367 | string absoluteFileName = buildPath(directoryMap[mapIndex].path, fileName); 368 | string relativeFilename = relativePath("/" ~ absoluteFileName, "/" ~ path); 369 | if (cookie && (info.mask & IN_MOVED_TO) == 0) 370 | { 371 | events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 372 | fromFilename.length = 0; 373 | cookie = 0; 374 | } 375 | if ((info.mask & IN_CREATE) != 0) 376 | { 377 | // If a dir/file is created and deleted immediately then 378 | // isDir will throw FileException(ENOENT) 379 | if (recursive) 380 | { 381 | stat_t dirCheck; 382 | if (stat(absoluteFileName.toStringz, &dirCheck) == 0) 383 | { 384 | if (S_ISDIR(dirCheck.st_mode)) 385 | addWatch(absoluteFileName); 386 | } 387 | else 388 | { 389 | const err = errno; 390 | if (err != ENOENT) 391 | throw new FileException(absoluteFileName, err); 392 | } 393 | } 394 | 395 | events ~= FileChangeEvent(FileChangeEventType.create, relativeFilename); 396 | } 397 | if ((info.mask & IN_DELETE) != 0) 398 | events ~= FileChangeEvent(FileChangeEventType.remove, relativeFilename); 399 | if ((info.mask & IN_MODIFY) != 0 || (info.mask & IN_ATTRIB) != 0) 400 | events ~= FileChangeEvent(FileChangeEventType.modify, relativeFilename); 401 | if ((info.mask & IN_MOVED_FROM) != 0) 402 | { 403 | fromFilename = fileName; 404 | cookie = info.cookie; 405 | } 406 | if ((info.mask & IN_MOVED_TO) != 0) 407 | { 408 | if (info.cookie == cookie) 409 | { 410 | events ~= FileChangeEvent(FileChangeEventType.rename, 411 | fromFilename, relativeFilename); 412 | } 413 | else 414 | events ~= FileChangeEvent(FileChangeEventType.create, relativeFilename); 415 | cookie = 0; 416 | } 417 | if ((info.mask & IN_DELETE_SELF) != 0 || (info.mask & IN_MOVE_SELF) != 0) 418 | { 419 | if (fd) 420 | { 421 | inotify_rm_watch(fd, info.wd); 422 | directoryMap[mapIndex].watched = false; 423 | } 424 | if (directoryMap[mapIndex].path == path) 425 | events ~= FileChangeEvent(FileChangeEventType.removeSelf, "."); 426 | } 427 | i += inotify_event.sizeof + info.len; 428 | if (i >= receivedBytes) 429 | break; 430 | } 431 | if (cookie) 432 | { 433 | events ~= FileChangeEvent(FileChangeEventType.remove, fromFilename); 434 | fromFilename.length = 0; 435 | cookie = 0; 436 | } 437 | } 438 | return events; 439 | } 440 | } 441 | else version (FSWUsesPolling) 442 | { 443 | import std.datetime : SysTime; 444 | import std.algorithm : countUntil, remove; 445 | import std.path : relativePath, absolutePath, baseName; 446 | 447 | private struct FileEntryCache 448 | { 449 | SysTime lastModification; 450 | const string name; 451 | bool isDirty; 452 | ulong uniqueHash; 453 | } 454 | 455 | private FileEntryCache[] cache; 456 | private bool isDir, recursive, exists; 457 | private SysTime timeLastModified; 458 | private string cwd; 459 | 460 | /// Generic fallback implementation using std.file.dirEntries 461 | this(string path, bool recursive = false, bool treatDirAsFile = false) 462 | { 463 | _path = path; 464 | cwd = getcwd; 465 | this.recursive = recursive; 466 | isDir = !treatDirAsFile; 467 | if (!isDir && recursive) 468 | throw new Exception("Can't recursively check on a file"); 469 | getEvents(); 470 | } 471 | 472 | /// Generic polling implementation 473 | FileChangeEvent[] getEvents() 474 | { 475 | const nowExists = path.exists; 476 | if (isDir && (!nowExists || path.isDir)) 477 | { 478 | FileChangeEvent[] events; 479 | if (nowExists && !exists) 480 | { 481 | exists = true; 482 | events ~= FileChangeEvent(FileChangeEventType.createSelf, "."); 483 | } 484 | if (!nowExists && exists) 485 | { 486 | exists = false; 487 | return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 488 | } 489 | if (!nowExists) 490 | return []; 491 | foreach (ref e; cache) 492 | e.isDirty = true; 493 | DirEntry[] created; 494 | foreach (file; dirEntries(path, recursive ? SpanMode.breadth : SpanMode.shallow)) 495 | { 496 | auto newCache = FileEntryCache(file.timeLastModified, 497 | file.name, false, file.getUniqueHash); 498 | bool found = false; 499 | foreach (ref cacheEntry; cache) 500 | { 501 | if (cacheEntry.name == newCache.name) 502 | { 503 | if (cacheEntry.lastModification != newCache.lastModification) 504 | { 505 | cacheEntry.lastModification = newCache.lastModification; 506 | events ~= FileChangeEvent(FileChangeEventType.modify, 507 | relativePath(file.name.absolutePath(cwd), 508 | path.absolutePath(cwd))); 509 | } 510 | cacheEntry.isDirty = false; 511 | found = true; 512 | break; 513 | } 514 | } 515 | if (!found) 516 | { 517 | cache ~= newCache; 518 | created ~= file; 519 | } 520 | } 521 | foreach_reverse (i, ref e; cache) 522 | { 523 | if (e.isDirty) 524 | { 525 | auto idx = created.countUntil!((a, b) => a.getUniqueHash == b.uniqueHash)(e); 526 | if (idx != -1) 527 | { 528 | events ~= FileChangeEvent(FileChangeEventType.rename, 529 | relativePath(e.name.absolutePath(cwd), 530 | path.absolutePath(cwd)), relativePath(created[idx].name.absolutePath(cwd), 531 | path.absolutePath(cwd))); 532 | created = created.remove(idx); 533 | } 534 | else 535 | { 536 | events ~= FileChangeEvent(FileChangeEventType.remove, 537 | relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 538 | } 539 | cache = cache.remove(i); 540 | } 541 | } 542 | foreach (ref e; created) 543 | { 544 | events ~= FileChangeEvent(FileChangeEventType.create, 545 | relativePath(e.name.absolutePath(cwd), path.absolutePath(cwd))); 546 | } 547 | if (events.length && events[0].type == FileChangeEventType.createSelf) 548 | return [events[0]]; 549 | return events; 550 | } 551 | else 552 | { 553 | if (nowExists && !exists) 554 | { 555 | exists = true; 556 | timeLastModified = path.timeLastModified; 557 | return [FileChangeEvent(FileChangeEventType.createSelf, ".")]; 558 | } 559 | else if (!nowExists && exists) 560 | { 561 | exists = false; 562 | return [FileChangeEvent(FileChangeEventType.removeSelf, ".")]; 563 | } 564 | else if (nowExists) 565 | { 566 | const modTime = path.timeLastModified; 567 | if (modTime != timeLastModified) 568 | { 569 | timeLastModified = modTime; 570 | return [FileChangeEvent(FileChangeEventType.modify, path.baseName)]; 571 | } 572 | else 573 | return []; 574 | } 575 | else 576 | return []; 577 | } 578 | } 579 | } 580 | else 581 | static assert(0, "No filesystem watching method?! Try setting version = FSWForcePoll;"); 582 | } 583 | 584 | /// 585 | unittest 586 | { 587 | import core.thread; 588 | 589 | FileChangeEvent waitForEvent(ref FileWatch watcher) 590 | { 591 | FileChangeEvent[] ret; 592 | while ((ret = watcher.getEvents()).length == 0) 593 | { 594 | Thread.sleep(1.msecs); 595 | } 596 | return ret[0]; 597 | } 598 | 599 | if (exists("test")) 600 | rmdirRecurse("test"); 601 | scope (exit) 602 | { 603 | if (exists("test")) 604 | rmdirRecurse("test"); 605 | } 606 | 607 | auto watcher = FileWatch("test", true); 608 | assert(watcher.path == "test"); 609 | mkdir("test"); 610 | auto ev = waitForEvent(watcher); 611 | assert(ev.type == FileChangeEventType.createSelf); 612 | write("test/a.txt", "abc"); 613 | ev = waitForEvent(watcher); 614 | assert(ev.type == FileChangeEventType.create); 615 | assert(ev.path == "a.txt"); 616 | Thread.sleep(2000.msecs); // for polling variant 617 | append("test/a.txt", "def"); 618 | ev = waitForEvent(watcher); 619 | assert(ev.type == FileChangeEventType.modify); 620 | assert(ev.path == "a.txt"); 621 | rename("test/a.txt", "test/b.txt"); 622 | ev = waitForEvent(watcher); 623 | assert(ev.type == FileChangeEventType.rename); 624 | assert(ev.path == "a.txt"); 625 | assert(ev.newPath == "b.txt"); 626 | remove("test/b.txt"); 627 | ev = waitForEvent(watcher); 628 | assert(ev.type == FileChangeEventType.remove); 629 | assert(ev.path == "b.txt"); 630 | rmdirRecurse("test"); 631 | ev = waitForEvent(watcher); 632 | assert(ev.type == FileChangeEventType.removeSelf); 633 | } 634 | 635 | version (linux) unittest 636 | { 637 | import core.thread; 638 | 639 | FileChangeEvent waitForEvent(ref FileWatch watcher, Duration timeout = 2.seconds) 640 | { 641 | FileChangeEvent[] ret; 642 | Duration elapsed; 643 | while ((ret = watcher.getEvents()).length == 0) 644 | { 645 | Thread.sleep(1.msecs); 646 | elapsed += 1.msecs; 647 | if (elapsed >= timeout) 648 | throw new Exception("timeout"); 649 | } 650 | return ret[0]; 651 | } 652 | 653 | if (exists("test2")) 654 | rmdirRecurse("test2"); 655 | if (exists("test3")) 656 | rmdirRecurse("test3"); 657 | scope (exit) 658 | { 659 | if (exists("test2")) 660 | rmdirRecurse("test2"); 661 | if (exists("test3")) 662 | rmdirRecurse("test3"); 663 | } 664 | 665 | auto watcher = FileWatch("test2", true); 666 | mkdir("test2"); 667 | auto ev = waitForEvent(watcher); 668 | assert(ev.type == FileChangeEventType.createSelf); 669 | write("test2/a.txt", "abc"); 670 | ev = waitForEvent(watcher); 671 | assert(ev.type == FileChangeEventType.create); 672 | assert(ev.path == "a.txt"); 673 | rename("test2/a.txt", "./testfile-a.txt"); 674 | ev = waitForEvent(watcher); 675 | assert(ev.type == FileChangeEventType.remove); 676 | assert(ev.path == "a.txt"); 677 | rename("./testfile-a.txt", "test2/b.txt"); 678 | ev = waitForEvent(watcher); 679 | assert(ev.type == FileChangeEventType.create); 680 | assert(ev.path == "b.txt"); 681 | remove("test2/b.txt"); 682 | ev = waitForEvent(watcher); 683 | assert(ev.type == FileChangeEventType.remove); 684 | assert(ev.path == "b.txt"); 685 | 686 | mkdir("test2/mydir"); 687 | rmdir("test2/mydir"); 688 | try 689 | { 690 | ev = waitForEvent(watcher); 691 | // waitForEvent only returns first event (just a test method anyway) because on windows or unprecise platforms events can be spawned multiple times 692 | // or could be never fired in case of slow polling mechanism 693 | assert(ev.type == FileChangeEventType.create); 694 | assert(ev.path == "mydir"); 695 | } 696 | catch (Exception e) 697 | { 698 | if (e.msg != "timeout") 699 | throw e; 700 | } 701 | 702 | version (FSWUsesINotify) 703 | { 704 | // test for creation, modification, removal of subdirectory 705 | mkdir("test2/subdir"); 706 | ev = waitForEvent(watcher); 707 | assert(ev.type == FileChangeEventType.create); 708 | assert(ev.path == "subdir"); 709 | write("test2/subdir/c.txt", "abc"); 710 | ev = waitForEvent(watcher); 711 | assert(ev.type == FileChangeEventType.create); 712 | assert(ev.path == "subdir/c.txt"); 713 | write("test2/subdir/c.txt", "\nabc"); 714 | ev = waitForEvent(watcher); 715 | assert(ev.type == FileChangeEventType.modify); 716 | assert(ev.path == "subdir/c.txt"); 717 | rmdirRecurse("test2/subdir"); 718 | auto events = watcher.getEvents(); 719 | assert(events[0].type == FileChangeEventType.remove); 720 | assert(events[0].path == "subdir/c.txt"); 721 | assert(events[1].type == FileChangeEventType.remove); 722 | assert(events[1].path == "subdir"); 723 | } 724 | // removal of watched folder 725 | rmdirRecurse("test2"); 726 | ev = waitForEvent(watcher); 727 | assert(ev.type == FileChangeEventType.removeSelf); 728 | assert(ev.path == "."); 729 | 730 | version (FSWUsesINotify) 731 | { 732 | // test for a subdirectory already present 733 | // both when recursive = true and recursive = false 734 | foreach (recursive; [true, false]) 735 | { 736 | mkdir("test3"); 737 | mkdir("test3/a"); 738 | mkdir("test3/a/b"); 739 | watcher = FileWatch("test3", recursive); 740 | write("test3/a/b/c.txt", "abc"); 741 | if (recursive) 742 | { 743 | ev = waitForEvent(watcher); 744 | assert(ev.type == FileChangeEventType.create); 745 | assert(ev.path == "a/b/c.txt"); 746 | } 747 | if (!recursive) 748 | { 749 | // creation of subdirectory and file within 750 | // test that addWatch doesn't get called 751 | mkdir("test3/d"); 752 | write("test3/d/e.txt", "abc"); 753 | auto revents = watcher.getEvents(); 754 | assert(revents.length == 1); 755 | assert(revents[0].type == FileChangeEventType.create); 756 | assert(revents[0].path == "d"); 757 | rmdirRecurse("test3/d"); 758 | revents = watcher.getEvents(); 759 | assert(revents.length == 1); 760 | assert(revents[0].type == FileChangeEventType.remove); 761 | assert(revents[0].path == "d"); 762 | } 763 | rmdirRecurse("test3"); 764 | events = watcher.getEvents(); 765 | if (recursive) 766 | { 767 | assert(events.length == 4); 768 | assert(events[0].type == FileChangeEventType.remove); 769 | assert(events[0].path == "a/b/c.txt"); 770 | assert(events[1].type == FileChangeEventType.remove); 771 | assert(events[1].path == "a/b"); 772 | assert(events[2].type == FileChangeEventType.remove); 773 | assert(events[2].path == "a"); 774 | assert(events[3].type == FileChangeEventType.removeSelf); 775 | assert(events[3].path == "."); 776 | } 777 | else 778 | { 779 | assert(events.length == 2); 780 | assert(events[0].type == FileChangeEventType.remove); 781 | assert(events[0].path == "a"); 782 | assert(events[1].type == FileChangeEventType.removeSelf); 783 | assert(events[1].path == "."); 784 | } 785 | } 786 | } 787 | } 788 | -------------------------------------------------------------------------------- /test.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | dub test 3 | dub test -d FSWTestRun2 -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | dub test 3 | dub test -d FSWTestRun2 --------------------------------------------------------------------------------