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     logger.infof("command to execute on change: %-(%s %)", conf.global.command);
69     logger.infof("setting up notification for changes of: %s", conf.global.paths);
70 
71     const cmd = () {
72         if (conf.global.useShell)
73             return ["/bin/sh", "-c"] ~ format!"%-(%s %)"(conf.global.command);
74         return conf.global.command;
75     }();
76 
77     auto monitor = Monitor(conf.global.paths, GlobFilter(conf.global.include,
78             conf.global.exclude), conf.global.watchMetadata
79             ? (ContentEvents | MetadataEvents) : ContentEvents);
80     logger.info("starting");
81 
82     auto handleExitStatus = HandleExitStatus(conf.global.useNotifySend);
83 
84     MonitorResult[] buildAndExecute(MonitorResult[] eventFiles) {
85         string[string] env;
86         MonitorResult[] rval;
87 
88         if (conf.global.setEnv) {
89             env["WATCHEXEC_EVENT"] = eventFiles.map!(a => format!"%s:%s"(a.kind,
90                     a.path)).joiner(";").text;
91         }
92 
93         auto p = spawnProcess(cmd, env).sandbox.timeout(conf.global.timeout)
94             .rcKill(conf.global.signal);
95 
96         if (conf.global.restart) {
97             while (!p.tryWait && rval.empty) {
98                 rval = monitor.wait(10.dur!"msecs");
99             }
100 
101             if (rval.empty) {
102                 handleExitStatus.exitStatus(p.status);
103             } else {
104                 p.kill(conf.global.signal);
105                 p.wait;
106                 logger.info("restarting command".color(Color.yellow));
107             }
108         } else {
109             p.wait;
110             handleExitStatus.exitStatus(p.status);
111         }
112 
113         if (conf.global.clearEvents) {
114             // the events can fire a bit late when e.g. writing to an NFS mount
115             // point.
116             monitor.collect(10.dur!"msecs");
117         }
118 
119         return rval;
120     }
121 
122     MonitorResult[] eventFiles;
123 
124     if (!conf.global.postPone) {
125         try {
126             eventFiles = buildAndExecute(null);
127         } catch (Exception e) {
128             logger.error(e.msg);
129             return 1;
130         }
131     }
132 
133     while (true) {
134         if (eventFiles.empty) {
135             eventFiles = monitor.wait(1000.dur!"weeks");
136         }
137 
138         foreach (changed; eventFiles) {
139             logger.tracef("%s changed", changed);
140         }
141 
142         if (!eventFiles.empty) {
143             if (conf.global.debounce != Duration.zero) {
144                 eventFiles ~= monitor.collect(conf.global.debounce);
145             }
146 
147             if (conf.global.clearScreen) {
148                 write("\033c");
149             }
150 
151             try {
152                 eventFiles = buildAndExecute(eventFiles);
153             } catch (Exception e) {
154                 logger.error(e.msg);
155                 return 1;
156             }
157 
158         }
159     }
160 }
161 
162 struct HandleExitStatus {
163     bool useNotifySend;
164     string notifyMsg;
165 
166     this(string notifyMsg) {
167         this.useNotifySend = !notifyMsg.empty;
168         this.notifyMsg = notifyMsg;
169     }
170 
171     void exitStatus(int code) {
172         import std.conv : to;
173         import std.process : spawnProcess, wait;
174 
175         immutable msgExitStatus = "exit status";
176         immutable msgOk = "✓";
177         immutable msgNok = "✗";
178 
179         if (useNotifySend) {
180             auto msg = () {
181                 if (code == 0)
182                     return format!"%s %s %s\n%s"(msgOk, msgExitStatus, code, notifyMsg);
183                 return format!"%s %s %s\n%s"(msgNok, msgExitStatus, code, notifyMsg);
184             }();
185 
186             spawnProcess([
187                     notifySendCmd, "-u", "normal", "-t", "3000", "-a", "watchexec",
188                     msg
189                     ]).wait;
190         }
191 
192         auto msg = () {
193             if (code == 0)
194                 return format!"%s %s"(msgOk, msgExitStatus.color(Color.green));
195             return format!"%s %s"(msgNok, msgExitStatus.color(Color.red));
196         }();
197 
198         logger.infof("%s %s", msg, code);
199     }
200 }
201 
202 struct AppConfig {
203     static import std.getopt;
204 
205     static struct Global {
206         import core.sys.posix.signal : SIGKILL;
207 
208         std.getopt.GetoptResult helpInfo;
209         VerboseMode verbosity;
210         bool help = true;
211 
212         AbsolutePath[] paths;
213         Duration debounce;
214         Duration timeout;
215         bool clearEvents;
216         bool clearScreen;
217         bool postPone;
218         bool restart;
219         bool setEnv;
220         bool useShell;
221         bool watchMetadata;
222         int signal = SIGKILL;
223         string progName;
224         string useNotifySend;
225         string[] command;
226 
227         string[] include;
228         string[] exclude;
229     }
230 
231     Global global;
232 
233     void printHelp() {
234         std.getopt.defaultGetoptPrinter(format(
235                 "Execute commands when watched files change\nusage: %s [options] -- <command>\n\noptions:",
236                 global.progName), global.helpInfo.options);
237     }
238 }
239 
240 AppConfig parseUserArgs(string[] args) {
241     import logger = std.experimental.logger;
242     import std.algorithm : countUntil, map;
243     import std.path : baseName;
244     import std.traits : EnumMembers;
245     import my.file : existsAnd, isFile, whichFromEnv;
246     static import std.getopt;
247 
248     AppConfig conf;
249     conf.global.progName = args[0].baseName;
250 
251     try {
252         const idx = countUntil(args, "--");
253         if (args.length > 1 && idx > 1) {
254             conf.global.command = args[idx + 1 .. $];
255             args = args[0 .. idx];
256         }
257 
258         bool clearEvents;
259         bool noDefaultIgnore;
260         bool noVcsIgnore;
261         string[] include;
262         string[] monitorExtensions;
263         string[] paths;
264         uint debounce = 200;
265         uint timeout = 3600;
266         // dfmt off
267         conf.global.helpInfo = std.getopt.getopt(args,
268             "clear-events", "clear the events that occured when executing the command", &clearEvents,
269             "c|clear", "clear screen before executing command",&conf.global.clearScreen,
270             "d|debounce", format!"set the timeout between detected change and command execution (default: %sms)"(debounce), &debounce,
271             "env", "set WATCHEXEC_*_PATH environment variables when executing the command", &conf.global.setEnv,
272             "exclude", "ignore modifications to paths matching the pattern (glob: <empty>)", &conf.global.exclude,
273             "e|ext", "file extensions, excluding dot, to watch (default: any)", &monitorExtensions,
274             "include", "ignore all modifications except those matching the pattern (glob: *)", &conf.global.include,
275             "meta", "watch for metadata changes (date, open/close, permission)", &conf.global.watchMetadata,
276             "no-default-ignore", "skip auto-ignoring of commonly ignored globs", &noDefaultIgnore,
277             "no-vcs-ignore", "skip auto-loading of .gitignore files for filtering", &noVcsIgnore,
278             "notify", format!"use %s for desktop notification with commands exit status and this msg"(notifySendCmd), &conf.global.useNotifySend,
279             "p|postpone", "wait until first change to execute command", &conf.global.postPone,
280             "r|restart", "restart the process if it's still running", &conf.global.restart,
281             "shell", "run the command in a shell (/bin/sh)", &conf.global.useShell,
282             "s|signal", "send signal to process upon changes, e.g. SIGHUP (default: SIGKILL)", &conf.global.signal,
283             "t|timeout", format!"max runtime of the command (default: %ss)"(timeout), &timeout,
284             "v|verbose", format("Set the verbosity (%-(%s, %))", [EnumMembers!(VerboseMode)]), &conf.global.verbosity,
285             "w|watch", "watch a specific directory", &paths,
286             );
287         // dfmt on
288 
289         conf.global.clearEvents = clearEvents;
290 
291         include ~= monitorExtensions.map!(a => format!"*.%s"(a)).array;
292         if (include.empty) {
293             conf.global.include = ["*"];
294         } else {
295             conf.global.include = include;
296         }
297 
298         if (!noVcsIgnore && existsAnd!isFile(Path(".gitignore"))) {
299             import std.file : readText;
300 
301             try {
302                 conf.global.exclude ~= parseGitIgnore(readText(".gitignore"));
303             } catch (Exception e) {
304                 logger.warning(e.msg);
305             }
306         } else if (!noDefaultIgnore && !noVcsIgnore) {
307             conf.global.exclude ~= defaultExclude;
308         }
309 
310         if (conf.global.useNotifySend) {
311             if (!whichFromEnv("PATH", notifySendCmd)) {
312                 conf.global.useNotifySend = null;
313                 logger.warningf("--notify requires the command %s", notifySendCmd);
314             }
315         }
316 
317         conf.global.timeout = timeout.dur!"seconds";
318         conf.global.debounce = debounce.dur!"msecs";
319         conf.global.paths = paths.map!(a => AbsolutePath(a)).array;
320 
321         conf.global.help = conf.global.helpInfo.helpWanted;
322     } catch (std.getopt.GetOptException e) {
323         // unknown option
324         logger.error(e.msg);
325     } catch (Exception e) {
326         logger.error(e.msg);
327     }
328 
329     return conf;
330 }
331 
332 /** Returns: the glob patterns from `content`.
333  *
334  * The syntax is the one found in .gitignore files.
335  */
336 string[] parseGitIgnore(string content) {
337     import std.algorithm : splitter;
338     import std.array : appender;
339     import std.ascii : newline;
340     import std.string : strip;
341 
342     auto app = appender!(string[])();
343 
344     foreach (l; content.splitter(newline).filter!(a => !a.empty)
345             .filter!(a => a[0] != '#')) {
346         app.put(l.strip);
347     }
348 
349     return app.data;
350 }
351 
352 @("shall parse a file with gitignore syntax")
353 unittest {
354     auto res = parseGitIgnore(`*.[oa]
355 *.obj
356 *.svn
357 
358 # editor junk files
359 *~
360 *.orig
361 tags
362 *.swp
363 
364 # dlang
365 build/
366 .dub
367 docs.json
368 __dummy.html
369 *.lst
370 __test__*__
371 
372 # rust
373 target/
374 **/*.rs.bk
375 
376 # python
377 *.pyc
378 
379 repo.tar.gz`);
380     assert(res == [
381             "*.[oa]", "*.obj", "*.svn", "*~", "*.orig", "tags", "*.swp", "build/",
382             ".dub", "docs.json", "__dummy.html", "*.lst", "__test__*__",
383             "target/", "**/*.rs.bk", "*.pyc", "repo.tar.gz"
384             ]);
385 }