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.core; 9 10 import std.conv; 11 static import std.file; 12 static import std.path; 13 import std.string; 14 15 /// If true, all commands will be echoed. By default, they will be 16 /// echoed to stdout, but you can override this with scriptlikeCustomEcho. 17 bool scriptlikeEcho = false; 18 19 /// Alias for backwards-compatibility. This will be deprecated in the future. 20 /// You should use scriptlikeEcho insetad. 21 alias scriptlikeTraceCommands = scriptlikeEcho; 22 23 /++ 24 If true, then run, tryRun, file write, file append, and all the echoable 25 commands that modify the filesystem will be echoed to stdout (regardless 26 of scriptlikeEcho) and NOT actually executed. 27 28 Warning! This is NOT a "set it and forget it" switch. You must still take 29 care to write your script in a way that's dryrun-safe. Two things to remember: 30 31 1. ONLY Scriptlike's functions will obey this setting. Calling Phobos 32 functions directly will BYPASS this setting. 33 34 2. If part of your script relies on a command having ACTUALLY been run, then 35 that command will fail. You must avoid that situation or work around it. 36 For example: 37 38 --------------------- 39 run(`date > tempfile`); 40 41 // The following will FAIL or behave INCORRECTLY in dryrun mode: 42 auto data = cast(string)read("tempfile"); 43 run("echo "~data); 44 --------------------- 45 46 That may be an unrealistic example, but it demonstrates the problem: Normally, 47 the code above should run fine (at least on posix). But in dryrun mode, 48 "date" will not actually be run. Therefore, tempfile will neither be created 49 nor overwritten. Result: Either an exception reading a non-existent file, 50 or outdated information will be displayed. 51 52 Scriptlike cannot anticipate or handle such situations. So it's up to you to 53 make sure your script is dryrun-safe. 54 +/ 55 bool scriptlikeDryRun = false; 56 57 /++ 58 By default, scriptlikeEcho and scriptlikeDryRun echo to stdout. 59 You can override this behavior by setting scriptlikeCustomEcho to your own 60 sink delegate. Since this is used for logging, don't forget to flush your output. 61 62 Reset this to null to go back to Scriptlike's default of "echo to stdout" again. 63 64 Note, setting this does not automatically enable echoing. You still need to 65 set either scriptlikeEcho or scriptlikeDryRun to true. 66 +/ 67 void delegate(string) scriptlikeCustomEcho; 68 69 /++ 70 Output text lazily through scriptlike's echo logger. 71 Does nothing if scriptlikeEcho and scriptlikeDryRun are both false. 72 73 The yapFunc version automatically prepends the output with the 74 name of the calling function. Ex: 75 76 ---------------- 77 void foo(int i = 42) { 78 // Outputs: 79 // foo: i = 42 80 yapFunc("i = ", i); 81 } 82 ---------------- 83 +/ 84 void yap(T...)(lazy T args) 85 { 86 import std.stdio; 87 88 if(scriptlikeEcho || scriptlikeDryRun) 89 { 90 if(scriptlikeCustomEcho) 91 scriptlikeCustomEcho(text(args)); 92 else 93 { 94 writeln(args); 95 stdout.flush(); 96 } 97 } 98 } 99 100 ///ditto 101 void yapFunc(string funcName=__FUNCTION__, T...)(lazy T args) 102 { 103 static assert(funcName != ""); 104 105 auto funcNameSimple = funcName.split(".")[$-1]; 106 yap(funcNameSimple, ": ", args); 107 } 108 109 /// Maintained for backwards-compatibility. Will be deprecated. 110 /// Use 'yap' instead. 111 void echoCommand(lazy string msg) 112 { 113 yap(msg); 114 } 115 116 /++ 117 Interpolated string (ie, variable expansion). 118 119 Any D expression can be placed inside ${ and }. Everything between the curly 120 braces will be evaluated inside your current scope, and passed as a parameter 121 (or parameters) to std.conv.text. 122 123 The curly braces do NOT nest, so variable expansion will end at the first 124 closing brace. If the closing brace is missing, an Exception will be thrown 125 at compile-time. 126 127 Example: 128 ------------ 129 // Output: The number 21 doubled is 42! 130 int num = 21; 131 writeln( mixin(interp!"The number ${num} doubled is ${num * 2}!") ); 132 133 // Output: Empty braces output nothing. 134 writeln( mixin(interp!"Empty ${}braces ${}output nothing.") ); 135 136 // Output: Multiple params: John Doe. 137 auto first = "John", last = "Doe"; 138 writeln( mixin(interp!`Multiple params: ${first, " ", last}.`) ); 139 ------------ 140 +/ 141 string interp(string str)() 142 { 143 enum State 144 { 145 normal, 146 dollar, 147 code, 148 } 149 150 auto state = State.normal; 151 152 string buf; 153 buf ~= '`'; 154 155 foreach(char c; str) 156 final switch(state) 157 { 158 case State.normal: 159 if(c == '$') 160 // Delay copying the $ until we find out whether it's 161 // the start of an escape sequence. 162 state = State.dollar; 163 else if(c == '`') 164 buf ~= "`~\"`\"~`"; 165 else 166 buf ~= c; 167 break; 168 169 case State.dollar: 170 if(c == '{') 171 { 172 state = State.code; 173 buf ~= "`~_interp_text("; 174 } 175 else if(c == '$') 176 buf ~= '$'; // Copy the previous $ 177 else 178 { 179 buf ~= '$'; // Copy the previous $ 180 buf ~= c; 181 state = State.normal; 182 } 183 break; 184 185 case State.code: 186 if(c == '}') 187 { 188 buf ~= ")~`"; 189 state = State.normal; 190 } 191 else 192 buf ~= c; 193 break; 194 } 195 196 // Finish up 197 final switch(state) 198 { 199 case State.normal: 200 buf ~= '`'; 201 break; 202 203 case State.dollar: 204 buf ~= "$`"; // Copy the previous $ 205 break; 206 207 case State.code: 208 throw new Exception( 209 "Interpolated string contains an unterminated expansion. "~ 210 "You're missing a closing curly brace." 211 ); 212 } 213 214 return buf; 215 } 216 string _interp_text(T...)(T args) 217 { 218 static if(T.length == 0) 219 return null; 220 else 221 return std.conv.text(args); 222 } 223 224 version(unittest_scriptlike_d) 225 unittest 226 { 227 import std.stdio; 228 writeln("Running Scriptlike unittests: interp"); stdout.flush(); 229 230 assert(mixin(interp!"hello") == "hello"); 231 assert(mixin(interp!"$") == "$"); 232 233 int num = 21; 234 assert( 235 mixin(interp!"The number ${num} doubled is ${num * 2}!") == 236 "The number 21 doubled is 42!" 237 ); 238 239 assert( 240 mixin(interp!"Empty ${}braces ${}output nothing.") == 241 "Empty braces output nothing." 242 ); 243 244 auto first = "John", last = "Doe"; 245 assert( 246 mixin(interp!`Multiple params: ${first, " ", last}.`) == 247 "Multiple params: John Doe." 248 ); 249 } 250 251 immutable gagEcho = q{ 252 auto _gagEcho_saveCustomEcho = scriptlikeCustomEcho; 253 254 scriptlikeCustomEcho = delegate(string str) {}; 255 scope(exit) 256 scriptlikeCustomEcho = _gagEcho_saveCustomEcho; 257 }; 258 259 version(unittest_scriptlike_d) 260 unittest 261 { 262 import std.stdio; 263 writeln("Running Scriptlike unittests: gagecho"); stdout.flush(); 264 265 // Test 1 266 scriptlikeEcho = true; 267 scriptlikeDryRun = true; 268 scriptlikeCustomEcho = null; 269 { 270 mixin(gagEcho); 271 assert(scriptlikeEcho == true); 272 assert(scriptlikeDryRun == true); 273 assert(scriptlikeCustomEcho != null); 274 } 275 assert(scriptlikeEcho == true); 276 assert(scriptlikeDryRun == true); 277 assert(scriptlikeCustomEcho == null); 278 279 // Test 2 280 scriptlikeEcho = false; 281 scriptlikeDryRun = false; 282 scriptlikeCustomEcho = null; 283 { 284 mixin(gagEcho); 285 assert(scriptlikeEcho == false); 286 assert(scriptlikeDryRun == false); 287 assert(scriptlikeCustomEcho != null); 288 } 289 assert(scriptlikeEcho == false); 290 assert(scriptlikeDryRun == false); 291 assert(scriptlikeCustomEcho == null); 292 293 // Test 3 294 void testEcho(string str) 295 { 296 import std.stdio; 297 writeln(str); 298 } 299 scriptlikeEcho = false; 300 scriptlikeDryRun = false; 301 scriptlikeCustomEcho = &testEcho; 302 { 303 mixin(gagEcho); 304 assert(scriptlikeEcho == false); 305 assert(scriptlikeDryRun == false); 306 assert(scriptlikeCustomEcho != null); 307 assert(scriptlikeCustomEcho != &testEcho); 308 } 309 assert(scriptlikeEcho == false); 310 assert(scriptlikeDryRun == false); 311 assert(scriptlikeCustomEcho == &testEcho); 312 } 313 314 /++ 315 Debugging aid: Output current file/line to stderr. 316 317 Also flushes stderr to ensure buffering and a subsequent crash don't 318 cause the message to get lost. 319 320 Example: 321 -------- 322 // Output example: 323 // src/myproj/myfile.d(42): trace 324 trace(); 325 -------- 326 +/ 327 template trace() 328 { 329 void trace(string file = __FILE__, size_t line = __LINE__)() 330 { 331 stderr.writeln(file, "(", line, "): trace"); 332 stderr.flush(); 333 } 334 } 335 336 /++ 337 Debugging aid: Output variable name/value and file/line info to stderr. 338 339 Also flushes stderr to ensure buffering and a subsequent crash don't 340 cause the message to get lost. 341 342 Example: 343 -------- 344 auto x = 5; 345 auto str = "Hello"; 346 347 // Output example: 348 // src/myproj/myfile.d(42): x: 5 349 // src/myproj/myfile.d(43): str: Hello 350 trace!x; 351 trace!str; 352 -------- 353 +/ 354 template trace(alias var) 355 { 356 void trace(string file = __FILE__, size_t line = __LINE__)() 357 { 358 stderr.writeln(file, "(", line, "): ", var.stringof, ": ", var); 359 stderr.flush(); 360 } 361 } 362 363 // Some tools for Scriptlike's unittests 364 version(unittest_scriptlike_d) 365 { 366 version(Posix) enum pwd = "pwd"; 367 else version(Windows) enum pwd = "cd"; 368 else static assert(0); 369 370 version(Posix) enum quiet = " >/dev/null 2>/dev/null"; 371 else version(Windows) enum quiet = " > NUL 2> NUL"; 372 else static assert(0); 373 374 string openSandbox(string func=__FUNCTION__)() 375 { 376 import scriptlike.file.wrappers; 377 import scriptlike.file.extras; 378 import scriptlike.path; 379 380 // Space in path is deliberate 381 auto sandboxDir = tempDir() ~ "scriptlike-d/test sandboxes" ~ func; 382 //import std.stdio; writeln("sandboxDir: ", sandboxDir.raw); 383 384 tryRmdirRecurse(sandboxDir); 385 mkdirRecurse(sandboxDir); 386 chdir(sandboxDir); 387 return sandboxDir.raw; 388 } 389 390 enum useSandbox = q{ 391 import std.stdio; 392 393 auto oldCwd = std.file.getcwd(); 394 auto sandboxDir = openSandbox(); 395 scope(success) // Don't cleanup upon failure, so the remains can be manually insepcted. 396 tryRmdirRecurse(sandboxDir); 397 scope(failure) 398 writeln("Sandbox directory: '", sandboxDir, "'"); 399 scope(exit) 400 std.file.chdir(oldCwd); 401 }; 402 403 immutable initTest(string testName, string msg = null, string module_ = __MODULE__) = ` 404 import std.stdio: writeln; 405 import std.exception; 406 import core.exception; 407 import scriptlike.core; 408 409 writeln("Testing `~module_~`: `~testName~`"); 410 scriptlikeEcho = false; 411 scriptlikeDryRun = false; 412 scriptlikeCustomEcho = null; 413 `; 414 415 // Generate a temporary filepath unique to the current process and current 416 // unittest block. Takes optional id number and path suffix. 417 // Guaranteed not to already exist. 418 // 419 // Path received can be used as either a file or dir, doesn't matter. 420 string tmpName(string id = null, string suffix = null, string func = __FUNCTION__) 421 out(result) 422 { 423 assert(!std.file.exists(result)); 424 } 425 body 426 { 427 import std.conv : text; 428 import std.process : thisProcessID; 429 430 // Include some spaces in the path, too: 431 auto withoutSuffix = std.path.buildPath( 432 std.file.tempDir(), 433 text("deleteme.script like.unit test.pid", thisProcessID, ".", func, ".", id) 434 ); 435 unittest_tryRemovePath(withoutSuffix); 436 437 // Add suffix 438 return std.path.buildPath(withoutSuffix, suffix); 439 } 440 441 // Get a unique temp pathname (guaranteed not to exist or collide), and 442 // clean up at the end up scope, deleting it if it exists. 443 // Path received can be used as either a file or dir, doesn't matter. 444 immutable useTmpName(string name, string suffix=null) = 445 name~" = tmpName(`"~name~"`, `"~suffix~"`); 446 scope(exit) unittest_tryRemovePath(tmpName(`"~name~"`)); 447 "; 448 449 // Delete if it already exists, regardless of whether it's a file or directory. 450 // Just like `tryRemovePath`, but intentionally ignores echo and dryrun modes. 451 void unittest_tryRemovePath(string path) 452 out 453 { 454 assert(!std.file.exists(path)); 455 } 456 body 457 { 458 if(std.file.exists(path)) 459 { 460 if(std.file.isDir(path)) 461 std.file.rmdirRecurse(path); 462 else 463 std.file.remove(path); 464 } 465 } 466 467 immutable checkResult = q{ 468 if(scriptlikeDryRun) 469 checkPre(); 470 else 471 checkPost(); 472 }; 473 474 // Runs the provided test in both normal and dryrun modes. 475 // The provided test can read scriptlikeDryRun and assert appropriately. 476 // 477 // Automatically ensures the test echoes in the echo and dryrun modes, 478 // and doesn't echo otherwise. 479 void testFileOperation(string funcName, string msg = null, string module_ = __MODULE__) 480 (void delegate() test) 481 { 482 static import std.stdio; 483 import std.stdio : writeln, stdout; 484 import std.algorithm : canFind; 485 486 string capturedEcho; 487 void captureEcho(string str) 488 { 489 capturedEcho ~= '\n'; 490 capturedEcho ~= str; 491 } 492 493 auto originalCurrentDir = std.file.getcwd(); 494 495 scope(exit) 496 { 497 scriptlikeEcho = false; 498 scriptlikeDryRun = false; 499 scriptlikeCustomEcho = null; 500 } 501 502 // Test normally 503 { 504 std.stdio.write("Testing ", module_, ".", funcName, (msg? ": " : ""), msg, "\t[normal]"); 505 stdout.flush(); 506 scriptlikeEcho = false; 507 scriptlikeDryRun = false; 508 capturedEcho = null; 509 scriptlikeCustomEcho = &captureEcho; 510 511 scope(failure) writeln(); 512 scope(exit) std.file.chdir(originalCurrentDir); 513 test(); 514 assert( 515 capturedEcho == "", 516 "Expected the test not to echo, but it echoed this:\n------------\n"~capturedEcho~"------------" 517 ); 518 } 519 520 // Test in echo mode 521 { 522 std.stdio.write(" [echo]"); 523 stdout.flush(); 524 scriptlikeEcho = true; 525 scriptlikeDryRun = false; 526 capturedEcho = null; 527 scriptlikeCustomEcho = &captureEcho; 528 529 scope(failure) writeln(); 530 scope(exit) std.file.chdir(originalCurrentDir); 531 test(); 532 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 533 assert( 534 capturedEcho.canFind("\n"~funcName~": "), 535 "Couldn't find '"~funcName~": ' in test's echo output:\n------------\n"~capturedEcho~"------------" 536 ); 537 } 538 539 // Test in dry run mode 540 { 541 std.stdio.write(" [dryrun]"); 542 stdout.flush(); 543 scriptlikeEcho = false; 544 scriptlikeDryRun = true; 545 capturedEcho = null; 546 scriptlikeCustomEcho = &captureEcho; 547 548 scope(failure) writeln(); 549 scope(exit) std.file.chdir(originalCurrentDir); 550 test(); 551 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 552 assert( 553 capturedEcho.canFind("\n"~funcName~": "), 554 "Couldn't find '"~funcName~": ' in the test's echo output:\n------------"~capturedEcho~"------------" 555 ); 556 } 557 558 writeln(); 559 } 560 561 unittest 562 { 563 mixin(initTest!"testFileOperation"); 564 565 testFileOperation!("testFileOperation", "Echo works 1")(() { 566 void testFileOperation() 567 { 568 yapFunc(); 569 } 570 testFileOperation(); 571 }); 572 573 testFileOperation!("testFileOperation", "Echo works 2")(() { 574 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 575 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 576 else {} 577 }); 578 579 { 580 auto countNormal = 0; 581 auto countEcho = 0; 582 auto countDryRun = 0; 583 testFileOperation!("testFileOperation", "Gets run in each mode")(() { 584 if(scriptlikeEcho) 585 { 586 countEcho++; 587 scriptlikeCustomEcho("testFileOperation: "); 588 } 589 else if(scriptlikeDryRun) 590 { 591 countDryRun++; 592 scriptlikeCustomEcho("testFileOperation: "); 593 } 594 else 595 countNormal++; 596 }); 597 assert(countNormal == 1); 598 assert(countEcho == 1); 599 assert(countDryRun == 1); 600 } 601 602 assertThrown!AssertError( 603 testFileOperation!("testFileOperation", "Echoing even with both echo and dryrun disabled")(() { 604 scriptlikeCustomEcho("testFileOperation: "); 605 }) 606 ); 607 608 assertThrown!AssertError( 609 testFileOperation!("testFileOperation", "No echo in echo mode")(() { 610 if(scriptlikeEcho) {} 611 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 612 else {} 613 }) 614 ); 615 616 assertThrown!AssertError( 617 testFileOperation!("testFileOperation", "No echo in dryrun mode")(() { 618 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 619 else if(scriptlikeDryRun) {} 620 else {} 621 }) 622 ); 623 } 624 }