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