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 }