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.process;
9 
10 import std.array;
11 import std.conv;
12 static import std.file;
13 static import std.path;
14 import std.process;
15 import std.range;
16 
17 import scriptlike.core;
18 import scriptlike.path;
19 import scriptlike.file;
20 
21 /// Indicates a command returned a non-zero errorlevel.
22 class ErrorLevelException : Exception
23 {
24 	int errorLevel;
25 	string command;
26 	
27 	/// The command's output is only available if the command was executed with
28 	/// runCollect. If it was executed with run, then Scriptlike doesn't have
29 	/// access to the output since it was simply sent straight to stdout/stderr.
30 	string output;
31 	
32 	this(int errorLevel, string command, string output=null, string file=__FILE__, size_t line=__LINE__)
33 	{
34 		this.errorLevel = errorLevel;
35 		this.command = command;
36 		this.output = output;
37 		auto msg = text("Command exited with error level ", errorLevel, ": ", command);
38 		if(output)
39 			msg ~= text("\nCommand's output:\n------\n", output, "------\n");
40 		super(msg, file, line);
41 	}
42 }
43 
44 /++
45 Runs a command, through the system's command shell interpreter,
46 in typical shell-script style: Synchronously, with the command's
47 stdout/in/err automatically forwarded through your
48 program's stdout/in/err.
49 
50 Optionally takes a working directory to run the command from.
51 
52 The command is echoed if scriptlikeEcho is true.
53 
54 ErrorLevelException is thrown if the process returns a non-zero error level.
55 If you want to handle the error level yourself, use tryRun instead of run.
56 
57 Example:
58 ---------------------
59 Args cmd;
60 cmd ~= Path("some tool");
61 cmd ~= "-o";
62 cmd ~= Path(`dir/out file.txt`);
63 cmd ~= ["--abc", "--def", "-g"];
64 Path("some working dir").run(cmd.data);
65 ---------------------
66 +/
67 void run(string command)
68 {
69 	yapFunc(command);
70 	mixin(gagEcho);
71 
72 	auto errorLevel = tryRun(command);
73 	if(errorLevel != 0)
74 		throw new ErrorLevelException(errorLevel, command);
75 }
76 
77 ///ditto
78 void run(Path workingDirectory, string command)
79 {
80 	auto saveDir = getcwd();
81 	workingDirectory.chdir();
82 	scope(exit) saveDir.chdir();
83 	
84 	run(command);
85 }
86 
87 version(unittest_scriptlike_d)
88 unittest
89 {
90 	import std.string : strip;
91 
92 	string scratchDir;
93 	string targetFile;
94 	string expectedContent;
95 	void checkPre()
96 	{
97 		assert(!std.file.exists(targetFile));
98 	}
99 
100 	void checkPost()
101 	{
102 		assert(std.file.exists(targetFile));
103 		assert(std.file.isFile(targetFile));
104 		assert(strip(cast(string) std.file.read(targetFile)) == expectedContent);
105 	}
106 
107 	testFileOperation!("run", "default dir")(() {
108 		mixin(useTmpName!"scratchDir");
109 		mixin(useTmpName!("targetFile", "dummy"));
110 		auto origDir = std.file.getcwd();
111 		scope(exit) std.file.chdir(origDir);
112 		std.file.mkdir(scratchDir);
113 		std.file.chdir(scratchDir);
114 		std.file.mkdir(std.path.dirName(targetFile));
115 		expectedContent = scratchDir;
116 
117 		checkPre();
118 		run(text(pwd, " > ", Path(targetFile)));
119 		mixin(checkResult);
120 	});
121 
122 	testFileOperation!("run", "custom dir")(() {
123 		mixin(useTmpName!"scratchDir");
124 		mixin(useTmpName!("targetFile", "dummy"));
125 		auto origDir = std.file.getcwd();
126 		scope(exit) std.file.chdir(origDir);
127 		std.file.mkdir(scratchDir);
128 		std.file.chdir(scratchDir);
129 		std.file.mkdir(std.path.dirName(targetFile));
130 		expectedContent = std.path.dirName(targetFile);
131 
132 		checkPre();
133 		run(Path(std.path.dirName(targetFile)), text(pwd, " > dummy"));
134 		mixin(checkResult);
135 	});
136 
137 	testFileOperation!("run", "bad command")(() {
138 		import std.exception : assertThrown;
139 
140 		void doIt()
141 		{
142 			run("cd this-path-does-not-exist-scriptlike"~quiet);
143 		}
144 
145 		if(scriptlikeDryRun)
146 			doIt();
147 		else
148 			assertThrown!ErrorLevelException( doIt() );
149 	});
150 }
151 
152 /++
153 Runs a command, through the system's command shell interpreter,
154 in typical shell-script style: Synchronously, with the command's
155 stdout/in/err automatically forwarded through your
156 program's stdout/in/err.
157 
158 Optionally takes a working directory to run the command from.
159 
160 The command is echoed if scriptlikeEcho is true.
161 
162 Returns: The error level the process exited with. Or -1 upon failure to
163 start the process.
164 
165 Example:
166 ---------------------
167 Args cmd;
168 cmd ~= Path("some tool");
169 cmd ~= "-o";
170 cmd ~= Path(`dir/out file.txt`);
171 cmd ~= ["--abc", "--def", "-g"];
172 auto errLevel = Path("some working dir").run(cmd.data);
173 ---------------------
174 +/
175 int tryRun(string command)
176 {
177 	yapFunc(command);
178 
179 	if(scriptlikeDryRun)
180 		return 0;
181 	else
182 	{
183 		try
184 			return spawnShell(command).wait();
185 		catch(Exception e)
186 			return -1;
187 	}
188 }
189 
190 ///ditto
191 int tryRun(Path workingDirectory, string command)
192 {
193 	auto saveDir = getcwd();
194 	workingDirectory.chdir();
195 	scope(exit) saveDir.chdir();
196 	
197 	return tryRun(command);
198 }
199 
200 version(unittest_scriptlike_d)
201 unittest
202 {
203 	import std.string : strip;
204 
205 	string scratchDir;
206 	string targetFile;
207 	string expectedContent;
208 	void checkPre()
209 	{
210 		assert(!std.file.exists(targetFile));
211 	}
212 
213 	void checkPost()
214 	{
215 		assert(std.file.exists(targetFile));
216 		assert(std.file.isFile(targetFile));
217 		assert(strip(cast(string) std.file.read(targetFile)) == expectedContent);
218 	}
219 
220 	testFileOperation!("tryRun", "default dir")(() {
221 		mixin(useTmpName!"scratchDir");
222 		mixin(useTmpName!("targetFile", "dummy"));
223 		auto origDir = std.file.getcwd();
224 		scope(exit) std.file.chdir(origDir);
225 		std.file.mkdir(scratchDir);
226 		std.file.chdir(scratchDir);
227 		std.file.mkdir(std.path.dirName(targetFile));
228 		expectedContent = scratchDir;
229 
230 		checkPre();
231 		tryRun(text(pwd, " > ", Path(targetFile)));
232 		mixin(checkResult);
233 	});
234 
235 	testFileOperation!("tryRun", "custom dir")(() {
236 		mixin(useTmpName!"scratchDir");
237 		mixin(useTmpName!("targetFile", "dummy"));
238 		auto origDir = std.file.getcwd();
239 		scope(exit) std.file.chdir(origDir);
240 		std.file.mkdir(scratchDir);
241 		std.file.chdir(scratchDir);
242 		std.file.mkdir(std.path.dirName(targetFile));
243 		expectedContent = std.path.dirName(targetFile);
244 
245 		checkPre();
246 		tryRun(Path(std.path.dirName(targetFile)), text(pwd, " > dummy"));
247 		mixin(checkResult);
248 	});
249 
250 	testFileOperation!("tryRun", "bad command")(() {
251 		import std.exception : assertNotThrown;
252 		mixin(useTmpName!"scratchDir");
253 		auto origDir = std.file.getcwd();
254 		scope(exit) std.file.chdir(origDir);
255 		std.file.mkdir(scratchDir);
256 		std.file.chdir(scratchDir);
257 
258 		assertNotThrown( tryRun("cd this-path-does-not-exist-scriptlike"~quiet) );
259 	});
260 }
261 
262 /// Backwards-compatibility alias. runShell may become deprecated in the
263 /// future, so you should use tryRun or run insetad.
264 alias runShell = tryRun;
265 
266 /// Similar to run(), but (like std.process.executeShell) captures and returns
267 /// the output instead of displaying it.
268 string runCollect(string command)
269 {
270 	yapFunc(command);
271 	mixin(gagEcho);
272 	
273 	auto result = tryRunCollect(command);
274 	if(result.status != 0)
275 		throw new ErrorLevelException(result.status, command, result.output);
276 
277 	return result.output;
278 }
279 
280 ///ditto
281 string runCollect(Path workingDirectory, string command)
282 {
283 	auto saveDir = getcwd();
284 	workingDirectory.chdir();
285 	scope(exit) saveDir.chdir();
286 	
287 	return runCollect(command);
288 }
289 
290 version(unittest_scriptlike_d)
291 unittest
292 {
293 	import std.string : strip;
294 	string dir;
295 	
296 	testFileOperation!("runCollect", "default dir")(() {
297 		auto result = runCollect(pwd);
298 		
299 		if(scriptlikeDryRun)
300 			assert(result == "");
301 		else
302 			assert(strip(result) == std.file.getcwd());
303 	});
304 
305 	testFileOperation!("runCollect", "custom dir")(() {
306 		mixin(useTmpName!"dir");
307 		std.file.mkdir(dir);
308 
309 		auto result = Path(dir).runCollect(pwd);
310 
311 		if(scriptlikeDryRun)
312 			assert(result == "");
313 		else
314 			assert(strip(result) == dir);
315 	});
316 
317 	testFileOperation!("runCollect", "bad command")(() {
318 		import std.exception : assertThrown;
319 
320 		void doIt()
321 		{
322 			runCollect("cd this-path-does-not-exist-scriptlike"~quiet);
323 		}
324 
325 		if(scriptlikeDryRun)
326 			doIt();
327 		else
328 			assertThrown!ErrorLevelException( doIt() );
329 	});
330 }
331 
332 /// Similar to tryRun(), but (like $(FULL_STD_PROCESS executeShell)) captures
333 /// and returns the output instead of displaying it.
334 /// 
335 /// Returns the same tuple as $(FULL_STD_PROCESS executeShell):
336 /// `std.typecons.Tuple!(int, "status", string, "output")`
337 ///
338 /// Returns: The `status` field will be -1 upon failure to
339 /// start the process.
340 auto tryRunCollect(string command)
341 {
342 	import std.typecons : Tuple;
343 
344 	yapFunc(command);
345 	auto result = Tuple!(int, "status", string, "output")(0, null);
346 
347 	if(scriptlikeDryRun)
348 		return result;
349 	else
350 	{
351 		try
352 			return executeShell(command);
353 		catch(Exception e)
354 		{
355 			result.status = -1;
356 			return result;
357 		}
358 	}
359 }
360 
361 ///ditto
362 auto tryRunCollect(Path workingDirectory, string command)
363 {
364 	auto saveDir = getcwd();
365 	workingDirectory.chdir();
366 	scope(exit) saveDir.chdir();
367 	
368 	return tryRunCollect(command);
369 }
370 
371 version(unittest_scriptlike_d)
372 unittest
373 {
374 	import std.string : strip;
375 	string dir;
376 	
377 	testFileOperation!("tryRunCollect", "default dir")(() {
378 		auto result = tryRunCollect(pwd);
379 		
380 		assert(result.status == 0);
381 		if(scriptlikeDryRun)
382 			assert(result.output == "");
383 		else
384 			assert(strip(result.output) == std.file.getcwd());
385 	});
386 
387 	testFileOperation!("tryRunCollect", "custom dir")(() {
388 		mixin(useTmpName!"dir");
389 		std.file.mkdir(dir);
390 
391 		auto result = Path(dir).tryRunCollect(pwd);
392 
393 		assert(result.status == 0);
394 		if(scriptlikeDryRun)
395 			assert(result.output == "");
396 		else
397 			assert(strip(result.output) == dir);
398 	});
399 
400 	testFileOperation!("tryRunCollect", "bad command")(() {
401 		import std.exception : assertThrown;
402 
403 		auto result = tryRunCollect("cd this-path-does-not-exist-scriptlike"~quiet);
404 		if(scriptlikeDryRun)
405 			assert(result.status == 0);
406 		else
407 			assert(result.status != 0);
408 		assert(result.output == "");
409 	});
410 }
411 
412 /++
413 Much like std.array.Appender!string, but specifically geared towards
414 building a command string out of arguments. String and Path can both
415 be appended. All elements added will automatically be escaped,
416 and separated by spaces, as necessary.
417 
418 Example:
419 -------------------
420 Args args;
421 args ~= Path(`some/big path/here/foobar`);
422 args ~= "-A";
423 args ~= "--bcd";
424 args ~= "Hello World";
425 args ~= Path("file.ext");
426 
427 // On windows:
428 assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`);
429 // On linux:
430 assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`);
431 -------------------
432 +/
433 struct Args
434 {
435 	// Internal note: For every element the user adds to ArgsT,
436 	// *two* elements will be added to this internal buf: first a spacer
437 	// (normally a space, or an empty string in the case of the very first
438 	// element the user adds) and then the actual element the user added.
439 	private Appender!(string) buf;
440 	private size_t _length = 0;
441 	
442 	void reserve(size_t newCapacity) @safe pure nothrow
443 	{
444 		// "*2" to account for the spacers
445 		buf.reserve(newCapacity * 2);
446 	}
447 
448 
449 	@property size_t capacity() const @safe pure nothrow
450 	{
451 		// "/2" to account for the spacers
452 		return buf.capacity / 2;
453 	}
454 
455 	@property string data() inout @trusted pure nothrow
456 	{
457 		return buf.data;
458 	}
459 	
460 	@property size_t length()
461 	{
462 		return _length;
463 	}
464 	
465 	private void putSpacer()
466 	{
467 		buf.put(_length==0? "" : " ");
468 	}
469 	
470 	void put(string item)
471 	{
472 		putSpacer();
473 		buf.put(escapeShellArg(item));
474 		_length += 2;
475 	}
476 
477 	void put(Path item)
478 	{
479 		put(item.toRawString());
480 	}
481 
482 	void put(Range)(Range items)
483 		if(
484 			isInputRange!Range &&
485 			(is(ElementType!Range == string) || is(ElementType!Range == Path))
486 		)
487 	{
488 		for(; !items.empty; items.popFront())
489 			put(items.front);
490 	}
491 
492 	void opOpAssign(string op)(string item) if(op == "~")
493 	{
494 		put(item);
495 	}
496 
497 	void opOpAssign(string op)(Path item) if(op == "~")
498 	{
499 		put(item);
500 	}
501 
502 	void opOpAssign(string op, Range)(Range items)
503 		if(
504 			op == "~" &&
505 			isInputRange!Range &&
506 			(is(ElementType!Range == string) || is(ElementType!Range == Path))
507 		)
508 	{
509 		put(items);
510 	}
511 }
512 
513 version(unittest_scriptlike_d)
514 unittest
515 {
516 	import std.stdio : writeln;
517 	writeln("Running Scriptlike unittests: Args");
518 
519 	Args args;
520 	args ~= Path(`some/big path/here/foobar`);
521 	args ~= "-A";
522 	args ~= "--bcd";
523 	args ~= "Hello World";
524 	args ~= Path("file.ext");
525 
526 	version(Windows)
527 		assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`);
528 	else version(Posix)
529 		assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`);
530 }