1 // Scriptlike: Utility to aid in script-like programs. 2 // Written in the D programming language. 3 4 /// Copyright: Copyright (C) 2014-2017 Nick Sabalausky 5 /// License: $(LINK2 https://github.com/Abscissa/scriptlike/blob/master/LICENSE.txt, zlib/libpng) 6 /// Authors: Nick Sabalausky 7 8 module scriptlike.process; 9 10 import std.array; 11 import std.conv; 12 static import std.file; 13 static import std.path; 14 import std.process; 15 import std.range; 16 17 import scriptlike.core; 18 import scriptlike.path; 19 import scriptlike.file; 20 21 /// Indicates a command returned a non-zero errorlevel. 22 class ErrorLevelException : Exception 23 { 24 int errorLevel; 25 string command; 26 27 /// The command's output is only available if the command was executed with 28 /// runCollect. If it was executed with run, then Scriptlike doesn't have 29 /// access to the output since it was simply sent straight to stdout/stderr. 30 string output; 31 32 this(int errorLevel, string command, string output=null, string file=__FILE__, size_t line=__LINE__) 33 { 34 this.errorLevel = errorLevel; 35 this.command = command; 36 this.output = output; 37 auto msg = text("Command exited with error level ", errorLevel, ": ", command); 38 if(output) 39 msg ~= text("\nCommand's output:\n------\n", output, "------\n"); 40 super(msg, file, line); 41 } 42 } 43 44 /++ 45 Runs a command, through the system's command shell interpreter, 46 in typical shell-script style: Synchronously, with the command's 47 stdout/in/err automatically forwarded through your 48 program's stdout/in/err. 49 50 Optionally takes a working directory to run the command from. 51 52 The command is echoed if scriptlikeEcho is true. 53 54 ErrorLevelException is thrown if the process returns a non-zero error level. 55 If you want to handle the error level yourself, use tryRun instead of run. 56 57 Example: 58 --------------------- 59 Args cmd; 60 cmd ~= Path("some tool"); 61 cmd ~= "-o"; 62 cmd ~= Path(`dir/out file.txt`); 63 cmd ~= ["--abc", "--def", "-g"]; 64 Path("some working dir").run(cmd.data); 65 --------------------- 66 +/ 67 void run(string command) 68 { 69 yapFunc(command); 70 mixin(gagEcho); 71 72 auto errorLevel = tryRun(command); 73 if(errorLevel != 0) 74 throw new ErrorLevelException(errorLevel, command); 75 } 76 77 ///ditto 78 void run(Path workingDirectory, string command) 79 { 80 auto saveDir = getcwd(); 81 workingDirectory.chdir(); 82 scope(exit) saveDir.chdir(); 83 84 run(command); 85 } 86 87 version(unittest_scriptlike_d) 88 unittest 89 { 90 import std..string : strip; 91 92 string scratchDir; 93 string targetFile; 94 string expectedContent; 95 void checkPre() 96 { 97 assert(!std.file.exists(targetFile)); 98 } 99 100 void checkPost() 101 { 102 assert(std.file.exists(targetFile)); 103 assert(std.file.isFile(targetFile)); 104 assert(strip(cast(string) std.file.read(targetFile)) == expectedContent); 105 } 106 107 testFileOperation!("run", "default dir")(() { 108 mixin(useTmpName!"scratchDir"); 109 mixin(useTmpName!("targetFile", "dummy")); 110 auto origDir = std.file.getcwd(); 111 scope(exit) std.file.chdir(origDir); 112 std.file.mkdir(scratchDir); 113 std.file.chdir(scratchDir); 114 std.file.mkdir(std.path.dirName(targetFile)); 115 expectedContent = scratchDir; 116 117 checkPre(); 118 run(text(pwd, " > ", Path(targetFile))); 119 mixin(checkResult); 120 }); 121 122 testFileOperation!("run", "custom dir")(() { 123 mixin(useTmpName!"scratchDir"); 124 mixin(useTmpName!("targetFile", "dummy")); 125 auto origDir = std.file.getcwd(); 126 scope(exit) std.file.chdir(origDir); 127 std.file.mkdir(scratchDir); 128 std.file.chdir(scratchDir); 129 std.file.mkdir(std.path.dirName(targetFile)); 130 expectedContent = std.path.dirName(targetFile); 131 132 checkPre(); 133 run(Path(std.path.dirName(targetFile)), text(pwd, " > dummy")); 134 mixin(checkResult); 135 }); 136 137 testFileOperation!("run", "bad command")(() { 138 import std.exception : assertThrown; 139 140 void doIt() 141 { 142 run("cd this-path-does-not-exist-scriptlike"~quiet); 143 } 144 145 if(scriptlikeDryRun) 146 doIt(); 147 else 148 assertThrown!ErrorLevelException( doIt() ); 149 }); 150 } 151 152 /++ 153 Runs a command, through the system's command shell interpreter, 154 in typical shell-script style: Synchronously, with the command's 155 stdout/in/err automatically forwarded through your 156 program's stdout/in/err. 157 158 Optionally takes a working directory to run the command from. 159 160 The command is echoed if scriptlikeEcho is true. 161 162 Returns: The error level the process exited with. Or -1 upon failure to 163 start the process. 164 165 Example: 166 --------------------- 167 Args cmd; 168 cmd ~= Path("some tool"); 169 cmd ~= "-o"; 170 cmd ~= Path(`dir/out file.txt`); 171 cmd ~= ["--abc", "--def", "-g"]; 172 auto errLevel = Path("some working dir").run(cmd.data); 173 --------------------- 174 +/ 175 int tryRun(string command) 176 { 177 yapFunc(command); 178 179 if(scriptlikeDryRun) 180 return 0; 181 else 182 { 183 try 184 return spawnShell(command).wait(); 185 catch(Exception e) 186 return -1; 187 } 188 } 189 190 ///ditto 191 int tryRun(Path workingDirectory, string command) 192 { 193 auto saveDir = getcwd(); 194 workingDirectory.chdir(); 195 scope(exit) saveDir.chdir(); 196 197 return tryRun(command); 198 } 199 200 version(unittest_scriptlike_d) 201 unittest 202 { 203 import std..string : strip; 204 205 string scratchDir; 206 string targetFile; 207 string expectedContent; 208 void checkPre() 209 { 210 assert(!std.file.exists(targetFile)); 211 } 212 213 void checkPost() 214 { 215 assert(std.file.exists(targetFile)); 216 assert(std.file.isFile(targetFile)); 217 assert(strip(cast(string) std.file.read(targetFile)) == expectedContent); 218 } 219 220 testFileOperation!("tryRun", "default dir")(() { 221 mixin(useTmpName!"scratchDir"); 222 mixin(useTmpName!("targetFile", "dummy")); 223 auto origDir = std.file.getcwd(); 224 scope(exit) std.file.chdir(origDir); 225 std.file.mkdir(scratchDir); 226 std.file.chdir(scratchDir); 227 std.file.mkdir(std.path.dirName(targetFile)); 228 expectedContent = scratchDir; 229 230 checkPre(); 231 tryRun(text(pwd, " > ", Path(targetFile))); 232 mixin(checkResult); 233 }); 234 235 testFileOperation!("tryRun", "custom dir")(() { 236 mixin(useTmpName!"scratchDir"); 237 mixin(useTmpName!("targetFile", "dummy")); 238 auto origDir = std.file.getcwd(); 239 scope(exit) std.file.chdir(origDir); 240 std.file.mkdir(scratchDir); 241 std.file.chdir(scratchDir); 242 std.file.mkdir(std.path.dirName(targetFile)); 243 expectedContent = std.path.dirName(targetFile); 244 245 checkPre(); 246 tryRun(Path(std.path.dirName(targetFile)), text(pwd, " > dummy")); 247 mixin(checkResult); 248 }); 249 250 testFileOperation!("tryRun", "bad command")(() { 251 import std.exception : assertNotThrown; 252 mixin(useTmpName!"scratchDir"); 253 auto origDir = std.file.getcwd(); 254 scope(exit) std.file.chdir(origDir); 255 std.file.mkdir(scratchDir); 256 std.file.chdir(scratchDir); 257 258 assertNotThrown( tryRun("cd this-path-does-not-exist-scriptlike"~quiet) ); 259 }); 260 } 261 262 /// Backwards-compatibility alias. runShell may become deprecated in the 263 /// future, so you should use tryRun or run insetad. 264 alias runShell = tryRun; 265 266 /// Similar to run(), but (like std.process.executeShell) captures and returns 267 /// the output instead of displaying it. 268 string runCollect(string command) 269 { 270 yapFunc(command); 271 mixin(gagEcho); 272 273 auto result = tryRunCollect(command); 274 if(result.status != 0) 275 throw new ErrorLevelException(result.status, command, result.output); 276 277 return result.output; 278 } 279 280 ///ditto 281 string runCollect(Path workingDirectory, string command) 282 { 283 auto saveDir = getcwd(); 284 workingDirectory.chdir(); 285 scope(exit) saveDir.chdir(); 286 287 return runCollect(command); 288 } 289 290 version(unittest_scriptlike_d) 291 unittest 292 { 293 import std..string : strip; 294 string dir; 295 296 testFileOperation!("runCollect", "default dir")(() { 297 auto result = runCollect(pwd); 298 299 if(scriptlikeDryRun) 300 assert(result == ""); 301 else 302 assert(strip(result) == std.file.getcwd()); 303 }); 304 305 testFileOperation!("runCollect", "custom dir")(() { 306 mixin(useTmpName!"dir"); 307 std.file.mkdir(dir); 308 309 auto result = Path(dir).runCollect(pwd); 310 311 if(scriptlikeDryRun) 312 assert(result == ""); 313 else 314 assert(strip(result) == dir); 315 }); 316 317 testFileOperation!("runCollect", "bad command")(() { 318 import std.exception : assertThrown; 319 320 void doIt() 321 { 322 runCollect("cd this-path-does-not-exist-scriptlike"~quiet); 323 } 324 325 if(scriptlikeDryRun) 326 doIt(); 327 else 328 assertThrown!ErrorLevelException( doIt() ); 329 }); 330 } 331 332 /// Similar to tryRun(), but (like $(FULL_STD_PROCESS executeShell)) captures 333 /// and returns the output instead of displaying it. 334 /// 335 /// Returns the same tuple as $(FULL_STD_PROCESS executeShell): 336 /// `std.typecons.Tuple!(int, "status", string, "output")` 337 /// 338 /// Returns: The `status` field will be -1 upon failure to 339 /// start the process. 340 auto tryRunCollect(string command) 341 { 342 import std.typecons : Tuple; 343 import std.traits : ReturnType; 344 345 yapFunc(command); 346 // Tuple!(int, "status", string, "output") on DMD 2.066 and up 347 // ProcessOutput on DMD 2.065 348 auto result = ReturnType!executeShell(0, null); 349 350 if(scriptlikeDryRun) 351 return result; 352 else 353 { 354 try 355 return executeShell(command); 356 catch(Exception e) 357 { 358 result.status = -1; 359 return result; 360 } 361 } 362 } 363 364 ///ditto 365 auto tryRunCollect(Path workingDirectory, string command) 366 { 367 auto saveDir = getcwd(); 368 workingDirectory.chdir(); 369 scope(exit) saveDir.chdir(); 370 371 return tryRunCollect(command); 372 } 373 374 version(unittest_scriptlike_d) 375 unittest 376 { 377 import std..string : strip; 378 string dir; 379 380 testFileOperation!("tryRunCollect", "default dir")(() { 381 auto result = tryRunCollect(pwd); 382 383 assert(result.status == 0); 384 if(scriptlikeDryRun) 385 assert(result.output == ""); 386 else 387 assert(strip(result.output) == std.file.getcwd()); 388 }); 389 390 testFileOperation!("tryRunCollect", "custom dir")(() { 391 mixin(useTmpName!"dir"); 392 std.file.mkdir(dir); 393 394 auto result = Path(dir).tryRunCollect(pwd); 395 396 assert(result.status == 0); 397 if(scriptlikeDryRun) 398 assert(result.output == ""); 399 else 400 assert(strip(result.output) == dir); 401 }); 402 403 testFileOperation!("tryRunCollect", "bad command")(() { 404 import std.exception : assertThrown; 405 406 auto result = tryRunCollect("cd this-path-does-not-exist-scriptlike"~quiet); 407 if(scriptlikeDryRun) 408 assert(result.status == 0); 409 else 410 assert(result.status != 0); 411 assert(result.output == ""); 412 }); 413 } 414 415 /++ 416 Much like std.array.Appender!string, but specifically geared towards 417 building a command string out of arguments. String and Path can both 418 be appended. All elements added will automatically be escaped, 419 and separated by spaces, as necessary. 420 421 Example: 422 ------------------- 423 Args args; 424 args ~= Path(`some/big path/here/foobar`); 425 args ~= "-A"; 426 args ~= "--bcd"; 427 args ~= "Hello World"; 428 args ~= Path("file.ext"); 429 430 // On windows: 431 assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`); 432 // On linux: 433 assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`); 434 ------------------- 435 +/ 436 struct Args 437 { 438 // Internal note: For every element the user adds to ArgsT, 439 // *two* elements will be added to this internal buf: first a spacer 440 // (normally a space, or an empty string in the case of the very first 441 // element the user adds) and then the actual element the user added. 442 private Appender!(string) buf; 443 private size_t _length = 0; 444 445 void reserve(size_t newCapacity) @safe pure nothrow 446 { 447 // "*2" to account for the spacers 448 buf.reserve(newCapacity * 2); 449 } 450 451 452 @property size_t capacity() const @safe pure nothrow 453 { 454 // "/2" to account for the spacers 455 return buf.capacity / 2; 456 } 457 458 @property string data() inout @trusted pure nothrow 459 { 460 return buf.data; 461 } 462 463 @property size_t length() 464 { 465 return _length; 466 } 467 468 private void putSpacer() 469 { 470 buf.put(_length==0? "" : " "); 471 } 472 473 void put(string item) 474 { 475 putSpacer(); 476 buf.put(escapeShellArg(item)); 477 _length += 2; 478 } 479 480 void put(Path item) 481 { 482 put(item.raw); 483 } 484 485 void put(Range)(Range items) 486 if( 487 isInputRange!Range && 488 (is(ElementType!Range == string) || is(ElementType!Range == Path)) 489 ) 490 { 491 for(; !items.empty; items.popFront()) 492 put(items.front); 493 } 494 495 void opOpAssign(string op)(string item) if(op == "~") 496 { 497 put(item); 498 } 499 500 void opOpAssign(string op)(Path item) if(op == "~") 501 { 502 put(item); 503 } 504 505 void opOpAssign(string op, Range)(Range items) 506 if( 507 op == "~" && 508 isInputRange!Range && 509 (is(ElementType!Range == string) || is(ElementType!Range == Path)) 510 ) 511 { 512 put(items); 513 } 514 } 515 516 version(unittest_scriptlike_d) 517 unittest 518 { 519 import std.stdio : writeln; 520 writeln("Running Scriptlike unittests: Args"); 521 522 Args args; 523 args ~= Path(`some/big path/here/foobar`); 524 args ~= "-A"; 525 args ~= "--bcd"; 526 args ~= "Hello World"; 527 args ~= Path("file.ext"); 528 529 version(Windows) 530 assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`); 531 else version(Posix) 532 assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`); 533 }