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 : writeln; 228 writeln("Running Scriptlike unittests: interp"); 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 : writeln; 263 writeln("Running Scriptlike unittests: gagecho"); 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 // Some tools for Scriptlike's unittests 315 version(unittest_scriptlike_d) 316 { 317 version(Posix) enum pwd = "pwd"; 318 else version(Windows) enum pwd = "cd"; 319 else static assert(0); 320 321 version(Posix) enum quiet = " >/dev/null 2>/dev/null"; 322 else version(Windows) enum quiet = " > NUL 2> NUL"; 323 else static assert(0); 324 325 immutable initTest(string testName, string msg = null, string module_ = __MODULE__) = ` 326 import std.stdio: writeln; 327 import std.exception; 328 import core.exception; 329 import scriptlike.core; 330 331 writeln("Testing `~module_~`: `~testName~`"); 332 scriptlikeEcho = false; 333 scriptlikeDryRun = false; 334 scriptlikeCustomEcho = null; 335 `; 336 337 // Generate a temporary filepath unique to the current process and current 338 // unittest block. Takes optional id number and path suffix. 339 // Guaranteed not to already exist. 340 // 341 // Path received can be used as either a file or dir, doesn't matter. 342 string tmpName(string id = null, string suffix = null, string func = __FUNCTION__) 343 out(result) 344 { 345 assert(!std.file.exists(result)); 346 } 347 body 348 { 349 import std.conv : text; 350 import std.process : thisProcessID; 351 352 // Include some spaces in the path, too: 353 auto withoutSuffix = std.path.buildPath( 354 std.file.tempDir(), 355 text("deleteme.script like.unit test.pid", thisProcessID, ".", func, ".", id) 356 ); 357 unittest_tryRemovePath(withoutSuffix); 358 359 // Add suffix 360 return std.path.buildPath(withoutSuffix, suffix); 361 } 362 363 // Get a unique temp pathname (guaranteed not to exist or collide), and 364 // clean up at the end up scope, deleting it if it exists. 365 // Path received can be used as either a file or dir, doesn't matter. 366 immutable useTmpName(string name, string suffix=null) = 367 name~" = tmpName(`"~name~"`, `"~suffix~"`); 368 scope(exit) unittest_tryRemovePath(tmpName(`"~name~"`)); 369 "; 370 371 // Delete if it already exists, regardless of whether it's a file or directory. 372 // Just like `tryRemovePath`, but intentionally ignores echo and dryrun modes. 373 void unittest_tryRemovePath(string path) 374 out 375 { 376 assert(!std.file.exists(path)); 377 } 378 body 379 { 380 if(std.file.exists(path)) 381 { 382 if(std.file.isDir(path)) 383 std.file.rmdirRecurse(path); 384 else 385 std.file.remove(path); 386 } 387 } 388 389 immutable checkResult = q{ 390 if(scriptlikeDryRun) 391 checkPre(); 392 else 393 checkPost(); 394 }; 395 396 // Runs the provided test in both normal and dryrun modes. 397 // The provided test can read scriptlikeDryRun and assert appropriately. 398 // 399 // Automatically ensures the test echoes in the echo and dryrun modes, 400 // and doesn't echo otherwise. 401 void testFileOperation(string funcName, string msg = null, string module_ = __MODULE__) 402 (void delegate() test) 403 { 404 static import std.stdio; 405 import std.stdio : writeln, stdout; 406 import std.algorithm : canFind; 407 408 string capturedEcho; 409 void captureEcho(string str) 410 { 411 capturedEcho ~= '\n'; 412 capturedEcho ~= str; 413 } 414 415 auto originalCurrentDir = std.file.getcwd(); 416 417 scope(exit) 418 { 419 scriptlikeEcho = false; 420 scriptlikeDryRun = false; 421 scriptlikeCustomEcho = null; 422 } 423 424 // Test normally 425 { 426 std.stdio.write("Testing ", module_, ".", funcName, (msg? ": " : ""), msg, "\t[normal]"); 427 stdout.flush(); 428 scriptlikeEcho = false; 429 scriptlikeDryRun = false; 430 capturedEcho = null; 431 scriptlikeCustomEcho = &captureEcho; 432 433 scope(failure) writeln(); 434 scope(exit) std.file.chdir(originalCurrentDir); 435 test(); 436 assert( 437 capturedEcho == "", 438 "Expected the test not to echo, but it echoed this:\n------------\n"~capturedEcho~"------------" 439 ); 440 } 441 442 // Test in echo mode 443 { 444 std.stdio.write(" [echo]"); 445 stdout.flush(); 446 scriptlikeEcho = true; 447 scriptlikeDryRun = false; 448 capturedEcho = null; 449 scriptlikeCustomEcho = &captureEcho; 450 451 scope(failure) writeln(); 452 scope(exit) std.file.chdir(originalCurrentDir); 453 test(); 454 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 455 assert( 456 capturedEcho.canFind("\n"~funcName~": "), 457 "Couldn't find '"~funcName~": ' in test's echo output:\n------------\n"~capturedEcho~"------------" 458 ); 459 } 460 461 // Test in dry run mode 462 { 463 std.stdio.write(" [dryrun]"); 464 stdout.flush(); 465 scriptlikeEcho = false; 466 scriptlikeDryRun = true; 467 capturedEcho = null; 468 scriptlikeCustomEcho = &captureEcho; 469 470 scope(failure) writeln(); 471 scope(exit) std.file.chdir(originalCurrentDir); 472 test(); 473 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 474 assert( 475 capturedEcho.canFind("\n"~funcName~": "), 476 "Couldn't find '"~funcName~": ' in the test's echo output:\n------------"~capturedEcho~"------------" 477 ); 478 } 479 480 writeln(); 481 } 482 483 unittest 484 { 485 mixin(initTest!"testFileOperation"); 486 487 testFileOperation!("testFileOperation", "Echo works 1")(() { 488 void testFileOperation() 489 { 490 yapFunc(); 491 } 492 testFileOperation(); 493 }); 494 495 testFileOperation!("testFileOperation", "Echo works 2")(() { 496 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 497 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 498 else {} 499 }); 500 501 { 502 auto countNormal = 0; 503 auto countEcho = 0; 504 auto countDryRun = 0; 505 testFileOperation!("testFileOperation", "Gets run in each mode")(() { 506 if(scriptlikeEcho) 507 { 508 countEcho++; 509 scriptlikeCustomEcho("testFileOperation: "); 510 } 511 else if(scriptlikeDryRun) 512 { 513 countDryRun++; 514 scriptlikeCustomEcho("testFileOperation: "); 515 } 516 else 517 countNormal++; 518 }); 519 assert(countNormal == 1); 520 assert(countEcho == 1); 521 assert(countDryRun == 1); 522 } 523 524 assertThrown!AssertError( 525 testFileOperation!("testFileOperation", "Echoing even with both echo and dryrun disabled")(() { 526 scriptlikeCustomEcho("testFileOperation: "); 527 }) 528 ); 529 530 assertThrown!AssertError( 531 testFileOperation!("testFileOperation", "No echo in echo mode")(() { 532 if(scriptlikeEcho) {} 533 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 534 else {} 535 }) 536 ); 537 538 assertThrown!AssertError( 539 testFileOperation!("testFileOperation", "No echo in dryrun mode")(() { 540 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 541 else if(scriptlikeDryRun) {} 542 else {} 543 }) 544 ); 545 } 546 }