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.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 	import std.traits : ReturnType;
344 
345 	yapFunc(command);
346 	// Tuple!(int, "status", string, "output") on DMD 2.066 and up
347 	// ProcessOutput on DMD 2.065
348 	auto result = ReturnType!executeShell(0, null);
349 
350 	if(scriptlikeDryRun)
351 		return result;
352 	else
353 	{
354 		try
355 			return executeShell(command);
356 		catch(Exception e)
357 		{
358 			result.status = -1;
359 			return result;
360 		}
361 	}
362 }
363 
364 ///ditto
365 auto tryRunCollect(Path workingDirectory, string command)
366 {
367 	auto saveDir = getcwd();
368 	workingDirectory.chdir();
369 	scope(exit) saveDir.chdir();
370 	
371 	return tryRunCollect(command);
372 }
373 
374 version(unittest_scriptlike_d)
375 unittest
376 {
377 	import std..string : strip;
378 	string dir;
379 	
380 	testFileOperation!("tryRunCollect", "default dir")(() {
381 		auto result = tryRunCollect(pwd);
382 		
383 		assert(result.status == 0);
384 		if(scriptlikeDryRun)
385 			assert(result.output == "");
386 		else
387 			assert(strip(result.output) == std.file.getcwd());
388 	});
389 
390 	testFileOperation!("tryRunCollect", "custom dir")(() {
391 		mixin(useTmpName!"dir");
392 		std.file.mkdir(dir);
393 
394 		auto result = Path(dir).tryRunCollect(pwd);
395 
396 		assert(result.status == 0);
397 		if(scriptlikeDryRun)
398 			assert(result.output == "");
399 		else
400 			assert(strip(result.output) == dir);
401 	});
402 
403 	testFileOperation!("tryRunCollect", "bad command")(() {
404 		import std.exception : assertThrown;
405 
406 		auto result = tryRunCollect("cd this-path-does-not-exist-scriptlike"~quiet);
407 		if(scriptlikeDryRun)
408 			assert(result.status == 0);
409 		else
410 			assert(result.status != 0);
411 		assert(result.output == "");
412 	});
413 }
414 
415 /++
416 Much like std.array.Appender!string, but specifically geared towards
417 building a command string out of arguments. String and Path can both
418 be appended. All elements added will automatically be escaped,
419 and separated by spaces, as necessary.
420 
421 Example:
422 -------------------
423 Args args;
424 args ~= Path(`some/big path/here/foobar`);
425 args ~= "-A";
426 args ~= "--bcd";
427 args ~= "Hello World";
428 args ~= Path("file.ext");
429 
430 // On windows:
431 assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`);
432 // On linux:
433 assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`);
434 -------------------
435 +/
436 struct Args
437 {
438 	// Internal note: For every element the user adds to ArgsT,
439 	// *two* elements will be added to this internal buf: first a spacer
440 	// (normally a space, or an empty string in the case of the very first
441 	// element the user adds) and then the actual element the user added.
442 	private Appender!(string) buf;
443 	private size_t _length = 0;
444 	
445 	void reserve(size_t newCapacity) @safe pure nothrow
446 	{
447 		// "*2" to account for the spacers
448 		buf.reserve(newCapacity * 2);
449 	}
450 
451 
452 	@property size_t capacity() const @safe pure nothrow
453 	{
454 		// "/2" to account for the spacers
455 		return buf.capacity / 2;
456 	}
457 
458 	@property string data() inout @trusted pure nothrow
459 	{
460 		return buf.data;
461 	}
462 	
463 	@property size_t length()
464 	{
465 		return _length;
466 	}
467 	
468 	private void putSpacer()
469 	{
470 		buf.put(_length==0? "" : " ");
471 	}
472 	
473 	void put(string item)
474 	{
475 		putSpacer();
476 		buf.put(escapeShellArg(item));
477 		_length += 2;
478 	}
479 
480 	void put(Path item)
481 	{
482 		put(item.raw);
483 	}
484 
485 	void put(Range)(Range items)
486 		if(
487 			isInputRange!Range &&
488 			(is(ElementType!Range == string) || is(ElementType!Range == Path))
489 		)
490 	{
491 		for(; !items.empty; items.popFront())
492 			put(items.front);
493 	}
494 
495 	void opOpAssign(string op)(string item) if(op == "~")
496 	{
497 		put(item);
498 	}
499 
500 	void opOpAssign(string op)(Path item) if(op == "~")
501 	{
502 		put(item);
503 	}
504 
505 	void opOpAssign(string op, Range)(Range items)
506 		if(
507 			op == "~" &&
508 			isInputRange!Range &&
509 			(is(ElementType!Range == string) || is(ElementType!Range == Path))
510 		)
511 	{
512 		put(items);
513 	}
514 }
515 
516 version(unittest_scriptlike_d)
517 unittest
518 {
519 	import std.stdio : writeln;
520 	writeln("Running Scriptlike unittests: Args");
521 
522 	Args args;
523 	args ~= Path(`some/big path/here/foobar`);
524 	args ~= "-A";
525 	args ~= "--bcd";
526 	args ~= "Hello World";
527 	args ~= Path("file.ext");
528 
529 	version(Windows)
530 		assert(args.data == `"some\big path\here\foobar" -A --bcd "Hello World" file.ext`);
531 	else version(Posix)
532 		assert(args.data == `'some/big path/here/foobar' -A --bcd 'Hello World' file.ext`);
533 }