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.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 }