1 /**
2 Copyright: Copyright (c) 2020, Joakim Brännström. All rights reserved.
3 License: $(LINK2 http://www.boost.org/LICENSE_1_0.txt, Boost Software License 1.0)
4 Author: Joakim Brännström (joakim.brannstrom@gmx.com)
5 */
6 module watchexec;
7 
8 import core.thread : Thread;
9 import logger = std.experimental.logger;
10 import std.algorithm : filter, map, joiner;
11 import std.array : array, empty;
12 import std.conv : text;
13 import std.datetime : Duration;
14 import std.datetime : dur, Clock;
15 import std.exception : collectException;
16 import std.format : format;
17 
18 import colorlog;
19 import my.filter;
20 import my.path;
21 import my.fswatch : Monitor, MonitorResult;
22 import my.filter : GlobFilter;
23 
24 version (unittest) {
25 } else {
26     int main(string[] args) {
27         confLogger(VerboseMode.info);
28 
29         auto conf = parseUserArgs(args);
30 
31         confLogger(conf.global.verbosity);
32         logger.trace(conf);
33 
34         if (conf.global.help)
35             return cliHelp(conf);
36         return cli(conf);
37     }
38 }
39 
40 private:
41 
42 immutable notifySendCmd = "notify-send";
43 
44 immutable defaultExclude = [
45     "*/.DS_Store", "*.py[co]", "*/#*#", "*/.#*", "*/.*.kate-swp", "*/.*.sw?",
46     "*/.*.sw?x", "*/.git/*"
47 ];
48 
49 int cliHelp(AppConfig conf) {
50     conf.printHelp;
51     return 0;
52 }
53 
54 int cli(AppConfig conf) {
55     import std.stdio : write, writeln;
56     import my.fswatch : ContentEvents, MetadataEvents;
57     import proc;
58 
59     if (conf.global.paths.empty) {
60         logger.error("No directories specified to watch");
61         return 1;
62     }
63     if (conf.global.command.empty) {
64         logger.error("No command to execute specified");
65         return 1;
66     }
67 
68     auto defaultFilter = GlobFilter(conf.global.include, conf.global.exclude);
69     auto recurseFilter = conf.global.useVcsIgnore
70         ? parseGitIgnoreRecursive(conf.global.include, conf.global.paths) : null;
71 
72     logger.infof("command to execute on change: %-(%s %)", conf.global.command);
73 
74     const cmd = () {
75         if (conf.global.useShell)
76             logger.info("--shell is deprecated");
77         return ["/bin/sh", "-c", format!"%-(%s %)"(conf.global.command)];
78     }();
79 
80     auto handleExitStatus = HandleExitStatus(conf.global.useNotifySend);
81 
82     if (conf.global.oneShotMode)
83         return cliOneshot(conf, cmd, handleExitStatus, defaultFilter);
84 
85     logger.infof("watching: %s", conf.global.paths);
86     logger.info("starting");
87 
88     auto monitor = Monitor(conf.global.paths, defaultFilter, recurseFilter,
89             conf.global.watchMetadata ? (ContentEvents | MetadataEvents) : ContentEvents);
90 
91     MonitorResult[] buildAndExecute(MonitorResult[] eventFiles) {
92         string[string] env;
93         MonitorResult[] rval;
94 
95         if (conf.global.setEnv) {
96             env["WATCHEXEC_EVENT"] = eventFiles.map!(a => format!"%s:%s"(a.kind,
97                     a.path)).joiner(";").text;
98         }
99 
100         auto p = spawnProcess(cmd, env).sandbox.timeout(conf.global.timeout)
101             .rcKill(conf.global.signal);
102 
103         if (conf.global.restart) {
104             while (!p.tryWait && rval.empty) {
105                 rval = monitor.wait(10.dur!"msecs");
106             }
107 
108             if (rval.empty) {
109                 handleExitStatus.exitStatus(p.status);
110             } else {
111                 p.kill(conf.global.signal);
112                 p.wait;
113                 logger.info("restarting command".color(Color.yellow));
114             }
115         } else {
116             p.wait;
117             handleExitStatus.exitStatus(p.status);
118         }
119 
120         if (conf.global.clearEvents) {
121             // the events can fire a bit late when e.g. writing to an NFS mount
122             // point.
123             monitor.collect(10.dur!"msecs");
124         }
125 
126         return rval;
127     }
128 
129     MonitorResult[] eventFiles;
130 
131     if (!conf.global.postPone) {
132         try {
133             eventFiles = buildAndExecute(null);
134         } catch (Exception e) {
135             logger.error(e.msg);
136             return 1;
137         }
138     }
139 
140     while (true) {
141         if (eventFiles.empty) {
142             eventFiles = monitor.wait(1000.dur!"weeks");
143         }
144 
145         foreach (changed; eventFiles) {
146             logger.tracef("%s changed", changed);
147         }
148 
149         if (!eventFiles.empty) {
150             if (conf.global.debounce != Duration.zero) {
151                 eventFiles ~= monitor.collect(conf.global.debounce);
152             }
153 
154             if (conf.global.clearScreen) {
155                 write("\033c");
156             }
157 
158             try {
159                 eventFiles = buildAndExecute(eventFiles);
160             } catch (Exception e) {
161                 logger.error(e.msg);
162                 return 1;
163             }
164 
165         }
166     }
167 }
168 
169 int cliOneshot(AppConfig conf, const string[] cmd,
170         HandleExitStatus handleExitStatus, GlobFilter defaultFilter) {
171     import std.algorithm : map, filter, joiner;
172     import std.datetime : Clock;
173     import std.file : exists, readText, isDir, isFile, timeLastModified, getSize;
174     import std.parallelism : taskPool, task;
175     import std.stdio : File;
176     import std.typecons : tuple, Tuple;
177     import my.file : existsAnd;
178     import my.optional;
179     import watchexec_internal.oneshot;
180 
181     static bool update(ref FileDb db, ref FileDb newDb, ref OneShotFile f) {
182         const changed = db.isChanged(f);
183 
184         if (changed)
185             newDb.add(f);
186         else if (auto v = f.path in db.files)
187             newDb.add(*v); // avoid checksum calculation
188         else
189             newDb.add(f);
190         return changed;
191     }
192 
193     auto db = () {
194         if (!exists(conf.global.jsonDb))
195             return FileDb.init;
196 
197         try {
198             logger.infof("Reading %s", conf.global.jsonDb);
199             return fromJson(readText(conf.global.jsonDb));
200         } catch (Exception e) {
201         }
202 
203         return FileDb.init;
204     }();
205 
206     bool isChanged;
207     FileDb newDb;
208 
209     static OneShotFile[] runOneshot(Tuple!(AbsolutePath, GlobFilter) data) {
210         return OneShotRange(data[0], data[1]).filter!(a => a.hasValue)
211             .map!(a => a.orElse(OneShotFile.init))
212             .array;
213     }
214 
215     try {
216         foreach (f; taskPool.amap!runOneshot(conf.global
217                 .paths
218                 .filter!(existsAnd!isDir)
219                 .map!(a => tuple(a, defaultFilter))
220                 .array).joiner) {
221             const changed = update(db, newDb, f);
222             isChanged = isChanged || changed;
223         }
224 
225         foreach (fname; conf.global.paths.filter!isFile) {
226             auto f = OneShotFile(AbsolutePath(fname), TimeStamp(timeLastModified(fname.toString,
227                     Clock.currTime).toUnixTime), FileSize(getSize(fname)));
228             const changed = update(db, newDb, f);
229             isChanged = isChanged || changed;
230         }
231     } catch (Exception e) {
232         logger.info(e.msg).collectException;
233     }
234 
235     int exitStatus;
236     if (isChanged) {
237         import proc;
238 
239         auto saveDb = task(() {
240             try {
241                 File(conf.global.jsonDb, "w").write(toJson(newDb));
242             } catch (Exception e) {
243                 logger.info(e.msg).collectException;
244             }
245         });
246         saveDb.executeInNewThread;
247 
248         auto p = spawnProcess(cmd).sandbox.timeout(conf.global.timeout).rcKill(conf.global.signal);
249         p.wait;
250         exitStatus = p.status;
251 
252         saveDb.yieldForce;
253 
254         logger.trace("old database: ", db);
255         logger.trace("new database: ", newDb);
256     }
257 
258     handleExitStatus.exitStatus(exitStatus);
259     return exitStatus;
260 }
261 
262 struct HandleExitStatus {
263     bool useNotifySend;
264     string notifyMsg;
265 
266     this(string notifyMsg) {
267         this.useNotifySend = !notifyMsg.empty;
268         this.notifyMsg = notifyMsg;
269     }
270 
271     void exitStatus(int code) {
272         import std.conv : to;
273         import std.process : spawnProcess, wait;
274 
275         immutable msgExitStatus = "exit status";
276         immutable msgOk = "✓";
277         immutable msgNok = "✗";
278 
279         if (useNotifySend) {
280             auto msg = () {
281                 if (code == 0)
282                     return format!"%s %s %s\n%s"(msgOk, msgExitStatus, code, notifyMsg);
283                 return format!"%s %s %s\n%s"(msgNok, msgExitStatus, code, notifyMsg);
284             }();
285 
286             spawnProcess([
287                     notifySendCmd, "-u", "normal", "-t", "3000", "-a", "watchexec",
288                     msg
289                     ]).wait;
290         }
291 
292         auto msg = () {
293             if (code == 0)
294                 return format!"%s %s"(msgOk, msgExitStatus.color(Color.green));
295             return format!"%s %s"(msgNok, msgExitStatus.color(Color.red));
296         }();
297 
298         logger.infof("%s %s", msg, code);
299     }
300 }
301 
302 struct AppConfig {
303     static import std.getopt;
304 
305     static struct Global {
306         import core.sys.posix.signal : SIGKILL;
307 
308         std.getopt.GetoptResult helpInfo;
309         VerboseMode verbosity;
310         bool help = true;
311 
312         AbsolutePath[] paths;
313         Duration debounce;
314         Duration timeout;
315         bool useVcsIgnore;
316         bool clearEvents;
317         bool clearScreen;
318         bool oneShotMode;
319         bool postPone;
320         bool restart;
321         bool setEnv;
322         bool useShell;
323         bool watchMetadata;
324         int signal = SIGKILL;
325         string jsonDb = "watchexec_db.json";
326         string progName;
327         string useNotifySend;
328         string[] command;
329 
330         string[] include;
331         string[] exclude;
332     }
333 
334     Global global;
335 
336     void printHelp() {
337         std.getopt.defaultGetoptPrinter(format(
338                 "Execute commands when watched files change\nusage: %s [options] -- <command>\n\noptions:",
339                 global.progName), global.helpInfo.options);
340     }
341 }
342 
343 AppConfig parseUserArgs(string[] args) {
344     import logger = std.experimental.logger;
345     import std.algorithm : countUntil, map;
346     import std.path : baseName;
347     import std.traits : EnumMembers;
348     import my.file : existsAnd, isFile, whichFromEnv;
349     static import std.getopt;
350 
351     AppConfig conf;
352     conf.global.progName = args[0].baseName;
353 
354     try {
355         const idx = countUntil(args, "--");
356         if (args.length > 1 && idx > 1) {
357             conf.global.command = args[idx + 1 .. $];
358             args = args[0 .. idx];
359         }
360 
361         bool clearEvents;
362         bool noDefaultIgnore;
363         bool noVcsIgnore;
364         string[] include;
365         string[] monitorExtensions;
366         string[] paths;
367         uint debounce = 200;
368         uint timeout = 3600;
369         // dfmt off
370         conf.global.helpInfo = std.getopt.getopt(args,
371             "clear-events", "clear the events that occured when executing the command", &clearEvents,
372             "c|clear", "clear screen before executing command",&conf.global.clearScreen,
373             "d|debounce", format!"set the timeout between detected change and command execution (default: %sms)"(debounce), &debounce,
374             "env", "set WATCHEXEC_EVENT environment variables when executing the command", &conf.global.setEnv,
375             "exclude", "ignore modifications to paths matching the pattern (glob: <empty>)", &conf.global.exclude,
376             "e|ext", "file extensions, excluding dot, to watch (default: any)", &monitorExtensions,
377             "include", "ignore all modifications except those matching the pattern (glob: *)", &conf.global.include,
378             "meta", "watch for metadata changes (date, open/close, permission)", &conf.global.watchMetadata,
379             "no-default-ignore", "skip auto-ignoring of commonly ignored globs", &noDefaultIgnore,
380             "no-vcs-ignore", "skip auto-loading of .gitignore files for filtering", &noVcsIgnore,
381             "notify", format!"use %s for desktop notification with commands exit status and this msg"(notifySendCmd), &conf.global.useNotifySend,
382             "o|oneshot", "run in one-shot mode where the command is executed if files are different from watchexec.json", &conf.global.oneShotMode,
383             "oneshot-db", "json database to use", &conf.global.jsonDb,
384             "p|postpone", "wait until first change to execute command", &conf.global.postPone,
385             "r|restart", "restart the process if it's still running", &conf.global.restart,
386             "shell", "(deprecated) run the command in a shell (/bin/sh)", &conf.global.useShell,
387             "s|signal", "send signal to process upon changes, e.g. SIGHUP (default: SIGKILL)", &conf.global.signal,
388             "t|timeout", format!"max runtime of the command (default: %ss)"(timeout), &timeout,
389             "v|verbose", format("Set the verbosity (%-(%s, %))", [EnumMembers!(VerboseMode)]), &conf.global.verbosity,
390             "w|watch", "watch a specific directory", &paths,
391             );
392         // dfmt on
393 
394         conf.global.clearEvents = clearEvents;
395 
396         include ~= monitorExtensions.map!(a => format!"*.%s"(a)).array;
397         if (include.empty) {
398             conf.global.include = ["*"];
399         } else {
400             conf.global.include = include;
401         }
402 
403         if (!noVcsIgnore && existsAnd!isFile(Path(".gitignore"))) {
404             import std.file : readText;
405 
406             try {
407                 conf.global.exclude ~= parseGitIgnore(readText(".gitignore"));
408             } catch (Exception e) {
409                 logger.warning(e.msg);
410             }
411         } else if (!noDefaultIgnore && !noVcsIgnore) {
412             conf.global.exclude ~= defaultExclude;
413         }
414         conf.global.useVcsIgnore = !noVcsIgnore;
415 
416         if (conf.global.useNotifySend) {
417             if (!whichFromEnv("PATH", notifySendCmd)) {
418                 conf.global.useNotifySend = null;
419                 logger.warningf("--notify requires the command %s", notifySendCmd);
420             }
421         }
422 
423         conf.global.timeout = timeout.dur!"seconds";
424         conf.global.debounce = debounce.dur!"msecs";
425         conf.global.paths = paths.map!(a => AbsolutePath(a)).array;
426 
427         conf.global.help = conf.global.helpInfo.helpWanted;
428     } catch (std.getopt.GetOptException e) {
429         // unknown option
430         logger.error(e.msg);
431     } catch (Exception e) {
432         logger.error(e.msg);
433     }
434 
435     return conf;
436 }
437 
438 /** Returns: the glob patterns from `content`.
439  *
440  * The syntax is the one found in .gitignore files.
441  */
442 string[] parseGitIgnore(string content) {
443     import std.algorithm : splitter;
444     import std.array : appender;
445     import std.ascii : newline;
446     import std.string : strip;
447 
448     auto app = appender!(string[])();
449 
450     foreach (l; content.splitter(newline).filter!(a => !a.empty)
451             .filter!(a => a[0] != '#')) {
452         app.put(l.strip);
453     }
454 
455     return app.data;
456 }
457 
458 @("shall parse a file with gitignore syntax")
459 unittest {
460     auto res = parseGitIgnore(`*.[oa]
461 *.obj
462 *.svn
463 
464 # editor junk files
465 *~
466 *.orig
467 tags
468 *.swp
469 
470 # dlang
471 build/
472 .dub
473 docs.json
474 __dummy.html
475 *.lst
476 __test__*__
477 
478 # rust
479 target/
480 **/*.rs.bk
481 
482 # python
483 *.pyc
484 
485 repo.tar.gz`);
486     assert(res == [
487             "*.[oa]", "*.obj", "*.svn", "*~", "*.orig", "tags", "*.swp", "build/",
488             ".dub", "docs.json", "__dummy.html", "*.lst", "__test__*__",
489             "target/", "**/*.rs.bk", "*.pyc", "repo.tar.gz"
490             ]);
491 }
492 
493 GlobFilter[AbsolutePath] parseGitIgnoreRecursive(string[] includes, AbsolutePath[] roots) {
494     import std.array : appender;
495     import std.file : dirEntries, SpanMode, isFile, readText;
496     import std.path : baseName, dirName;
497 
498     GlobFilter[AbsolutePath] rval;
499 
500     foreach (gf; roots.map!(a => dirEntries(a.toString, SpanMode.depth))
501             .joiner
502             .filter!isFile
503             .filter!(a => a.baseName == ".gitignore")) {
504         try {
505             rval[AbsolutePath(gf.name.dirName)] = GlobFilter(includes, parseGitIgnore(readText(gf)));
506         } catch (Exception e) {
507         }
508     }
509 
510     return rval;
511 }