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 }