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 }