1 // Scriptlike: Utility to aid in script-like programs. 2 // Written in the D programming language. 3 4 /// Copyright: Copyright (C) 2014-2016 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 alias _interp_text = std.conv.text; 217 218 version(unittest_scriptlike_d) 219 unittest 220 { 221 import std.stdio : writeln; 222 writeln("Running Scriptlike unittests: interp"); 223 224 assert(mixin(interp!"hello") == "hello"); 225 assert(mixin(interp!"$") == "$"); 226 227 int num = 21; 228 assert( 229 mixin(interp!"The number ${num} doubled is ${num * 2}!") == 230 "The number 21 doubled is 42!" 231 ); 232 233 assert( 234 mixin(interp!"Empty ${}braces ${}output nothing.") == 235 "Empty braces output nothing." 236 ); 237 238 auto first = "John", last = "Doe"; 239 assert( 240 mixin(interp!`Multiple params: ${first, " ", last}.`) == 241 "Multiple params: John Doe." 242 ); 243 } 244 245 immutable gagEcho = q{ 246 auto _gagEcho_saveCustomEcho = scriptlikeCustomEcho; 247 248 scriptlikeCustomEcho = delegate(string str) {}; 249 scope(exit) 250 scriptlikeCustomEcho = _gagEcho_saveCustomEcho; 251 }; 252 253 version(unittest_scriptlike_d) 254 unittest 255 { 256 import std.stdio : writeln; 257 writeln("Running Scriptlike unittests: gagecho"); 258 259 // Test 1 260 scriptlikeEcho = true; 261 scriptlikeDryRun = true; 262 scriptlikeCustomEcho = null; 263 { 264 mixin(gagEcho); 265 assert(scriptlikeEcho == true); 266 assert(scriptlikeDryRun == true); 267 assert(scriptlikeCustomEcho != null); 268 } 269 assert(scriptlikeEcho == true); 270 assert(scriptlikeDryRun == true); 271 assert(scriptlikeCustomEcho == null); 272 273 // Test 2 274 scriptlikeEcho = false; 275 scriptlikeDryRun = false; 276 scriptlikeCustomEcho = null; 277 { 278 mixin(gagEcho); 279 assert(scriptlikeEcho == false); 280 assert(scriptlikeDryRun == false); 281 assert(scriptlikeCustomEcho != null); 282 } 283 assert(scriptlikeEcho == false); 284 assert(scriptlikeDryRun == false); 285 assert(scriptlikeCustomEcho == null); 286 287 // Test 3 288 void testEcho(string str) 289 { 290 import std.stdio; 291 writeln(str); 292 } 293 scriptlikeEcho = false; 294 scriptlikeDryRun = false; 295 scriptlikeCustomEcho = &testEcho; 296 { 297 mixin(gagEcho); 298 assert(scriptlikeEcho == false); 299 assert(scriptlikeDryRun == false); 300 assert(scriptlikeCustomEcho != null); 301 assert(scriptlikeCustomEcho != &testEcho); 302 } 303 assert(scriptlikeEcho == false); 304 assert(scriptlikeDryRun == false); 305 assert(scriptlikeCustomEcho == &testEcho); 306 } 307 308 // Some tools for Scriptlike's unittests 309 version(unittest_scriptlike_d) 310 { 311 version(Posix) enum pwd = "pwd"; 312 else version(Windows) enum pwd = "cd"; 313 else static assert(0); 314 315 version(Posix) enum quiet = " >/dev/null 2>/dev/null"; 316 else version(Windows) enum quiet = " > NUL 2> NUL"; 317 else static assert(0); 318 319 immutable initTest(string testName, string msg = null, string module_ = __MODULE__) = ` 320 import std.stdio: writeln; 321 import std.exception; 322 import core.exception; 323 import scriptlike.core; 324 325 writeln("Testing `~module_~`: `~testName~`"); 326 scriptlikeEcho = false; 327 scriptlikeDryRun = false; 328 scriptlikeCustomEcho = null; 329 `; 330 331 // Generate a temporary filepath unique to the current process and current 332 // unittest block. Takes optional id number and path suffix. 333 // Guaranteed not to already exist. 334 // 335 // Path received can be used as either a file or dir, doesn't matter. 336 string tmpName(string id = null, string suffix = null, string func = __FUNCTION__) 337 out(result) 338 { 339 assert(!std.file.exists(result)); 340 } 341 body 342 { 343 import std.conv : text; 344 import std.process : thisProcessID; 345 346 // Include some spaces in the path, too: 347 auto withoutSuffix = std.path.buildPath( 348 std.file.tempDir(), 349 text("deleteme.script like.unit test.pid", thisProcessID, ".", func, ".", id) 350 ); 351 unittest_tryRemovePath(withoutSuffix); 352 353 // Add suffix 354 return std.path.buildPath(withoutSuffix, suffix); 355 } 356 357 // Get a unique temp pathname (guaranteed not to exist or collide), and 358 // clean up at the end up scope, deleting it if it exists. 359 // Path received can be used as either a file or dir, doesn't matter. 360 immutable useTmpName(string name, string suffix=null) = 361 name~" = tmpName(`"~name~"`, `"~suffix~"`); 362 scope(exit) unittest_tryRemovePath(tmpName(`"~name~"`)); 363 "; 364 365 // Delete if it already exists, regardless of whether it's a file or directory. 366 // Just like `tryRemovePath`, but intentionally ignores echo and dryrun modes. 367 void unittest_tryRemovePath(string path) 368 out 369 { 370 assert(!std.file.exists(path)); 371 } 372 body 373 { 374 if(std.file.exists(path)) 375 { 376 if(std.file.isDir(path)) 377 std.file.rmdirRecurse(path); 378 else 379 std.file.remove(path); 380 } 381 } 382 383 immutable checkResult = q{ 384 if(scriptlikeDryRun) 385 checkPre(); 386 else 387 checkPost(); 388 }; 389 390 // Runs the provided test in both normal and dryrun modes. 391 // The provided test can read scriptlikeDryRun and assert appropriately. 392 // 393 // Automatically ensures the test echoes in the echo and dryrun modes, 394 // and doesn't echo otherwise. 395 void testFileOperation(string funcName, string msg = null, string module_ = __MODULE__) 396 (void delegate() test) 397 { 398 static import std.stdio; 399 import std.stdio : writeln, stdout; 400 import std.algorithm : canFind; 401 402 string capturedEcho; 403 void captureEcho(string str) 404 { 405 capturedEcho ~= '\n'; 406 capturedEcho ~= str; 407 } 408 409 auto originalCurrentDir = std.file.getcwd(); 410 411 scope(exit) 412 { 413 scriptlikeEcho = false; 414 scriptlikeDryRun = false; 415 scriptlikeCustomEcho = null; 416 } 417 418 // Test normally 419 { 420 std.stdio.write("Testing ", module_, ".", funcName, (msg? ": " : ""), msg, "\t[normal]"); 421 stdout.flush(); 422 scriptlikeEcho = false; 423 scriptlikeDryRun = false; 424 capturedEcho = null; 425 scriptlikeCustomEcho = &captureEcho; 426 427 scope(failure) writeln(); 428 scope(exit) std.file.chdir(originalCurrentDir); 429 test(); 430 assert( 431 capturedEcho == "", 432 "Expected the test not to echo, but it echoed this:\n------------\n"~capturedEcho~"------------" 433 ); 434 } 435 436 // Test in echo mode 437 { 438 std.stdio.write(" [echo]"); 439 stdout.flush(); 440 scriptlikeEcho = true; 441 scriptlikeDryRun = false; 442 capturedEcho = null; 443 scriptlikeCustomEcho = &captureEcho; 444 445 scope(failure) writeln(); 446 scope(exit) std.file.chdir(originalCurrentDir); 447 test(); 448 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 449 assert( 450 capturedEcho.canFind("\n"~funcName~": "), 451 "Couldn't find '"~funcName~": ' in test's echo output:\n------------\n"~capturedEcho~"------------" 452 ); 453 } 454 455 // Test in dry run mode 456 { 457 std.stdio.write(" [dryrun]"); 458 stdout.flush(); 459 scriptlikeEcho = false; 460 scriptlikeDryRun = true; 461 capturedEcho = null; 462 scriptlikeCustomEcho = &captureEcho; 463 464 scope(failure) writeln(); 465 scope(exit) std.file.chdir(originalCurrentDir); 466 test(); 467 assert(capturedEcho != "", "Expected the test to echo, but it didn't."); 468 assert( 469 capturedEcho.canFind("\n"~funcName~": "), 470 "Couldn't find '"~funcName~": ' in the test's echo output:\n------------"~capturedEcho~"------------" 471 ); 472 } 473 474 writeln(); 475 } 476 477 unittest 478 { 479 mixin(initTest!"testFileOperation"); 480 481 testFileOperation!("testFileOperation", "Echo works 1")(() { 482 void testFileOperation() 483 { 484 yapFunc(); 485 } 486 testFileOperation(); 487 }); 488 489 testFileOperation!("testFileOperation", "Echo works 2")(() { 490 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 491 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 492 else {} 493 }); 494 495 { 496 auto countNormal = 0; 497 auto countEcho = 0; 498 auto countDryRun = 0; 499 testFileOperation!("testFileOperation", "Gets run in each mode")(() { 500 if(scriptlikeEcho) 501 { 502 countEcho++; 503 scriptlikeCustomEcho("testFileOperation: "); 504 } 505 else if(scriptlikeDryRun) 506 { 507 countDryRun++; 508 scriptlikeCustomEcho("testFileOperation: "); 509 } 510 else 511 countNormal++; 512 }); 513 assert(countNormal == 1); 514 assert(countEcho == 1); 515 assert(countDryRun == 1); 516 } 517 518 assertThrown!AssertError( 519 testFileOperation!("testFileOperation", "Echoing even with both echo and dryrun disabled")(() { 520 scriptlikeCustomEcho("testFileOperation: "); 521 }) 522 ); 523 524 assertThrown!AssertError( 525 testFileOperation!("testFileOperation", "No echo in echo mode")(() { 526 if(scriptlikeEcho) {} 527 else if(scriptlikeDryRun) scriptlikeCustomEcho("testFileOperation: "); 528 else {} 529 }) 530 ); 531 532 assertThrown!AssertError( 533 testFileOperation!("testFileOperation", "No echo in dryrun mode")(() { 534 if(scriptlikeEcho) scriptlikeCustomEcho("testFileOperation: "); 535 else if(scriptlikeDryRun) {} 536 else {} 537 }) 538 ); 539 } 540 }