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