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 }