1 // Scriptlike: Utility to aid in script-like programs.
2 // Written in the D programming language.
3 
4 /// Copyright: Copyright (C) 2014-2015 Nick Sabalausky
5 /// License:   zlib/libpng
6 /// Authors:   Nick Sabalausky
7 
8 module scriptlike.path;
9 
10 import std.algorithm;
11 import std.conv;
12 import std.datetime;
13 import std.file;
14 import std.process;
15 import std.range;
16 import std.stdio;
17 import std.string;
18 import std.traits;
19 import std.typecons;
20 import std.typetuple;
21 
22 static import std.path;
23 public import std.path : dirSeparator, pathSeparator, isDirSeparator,
24 	CaseSensitive, osDefaultCaseSensitivity, buildPath, buildNormalizedPath;
25 
26 /// Represents a file extension.
27 struct Ext
28 {
29 	private string str;
30 	
31 	/// Main constructor.
32 	this(string extension = null) pure @safe nothrow
33 	{
34 		this.str = extension;
35 	}
36 	
37 	/// Convert to string.
38 	string toString() pure @safe nothrow
39 	{
40 		return str;
41 	}
42 	
43 	/// No longer needed. Use Ext.toString() instead.
44 	string toRawString()
45 	{
46 		return str;
47 	}
48 	
49 	/// Compare using OS-specific case-sensitivity rules. If you want to force
50 	/// case-sensitive or case-insensistive, then call filenameCmp instead.
51 	int opCmp(ref const Ext other) const
52 	{
53 		return std.path.filenameCmp(this.str, other.str);
54 	}
55 
56 	///ditto
57 	int opCmp(Ext other) const
58 	{
59 		return std.path.filenameCmp(this.str, other.str);
60 	}
61 
62 	///ditto
63 	int opCmp(string other) const
64 	{
65 		return std.path.filenameCmp(this.str, other);
66 	}
67 
68 	/// Compare using OS-specific case-sensitivity rules. If you want to force
69 	/// case-sensitive or case-insensistive, then call filenameCmp instead.
70 	int opEquals(ref const Ext other) const
71 	{
72 		return opCmp(other) == 0;
73 	}
74 
75 	///ditto
76 	int opEquals(Ext other) const
77 	{
78 		return opCmp(other) == 0;
79 	}
80 
81 	///ditto
82 	int opEquals(string other) const
83 	{
84 		return opCmp(other) == 0;
85 	}
86 
87 	/// Convert to bool
88 	T opCast(T)() if(is(T==bool))
89 	{
90 		return !!str;
91 	}
92 }
93 
94 /// Represents a filesystem path. The path is always kept normalized
95 /// automatically (as performed by buildNormalizedPathFixed).
96 struct Path
97 {
98 	private string str = ".";
99 	
100 	/// Main constructor.
101 	this(string path = ".") @safe pure nothrow
102 	{
103 		this.str = buildNormalizedPathFixed(path);
104 	}
105 	
106 	@trusted pure nothrow invariant()
107 	{
108 		assert(str == buildNormalizedPathFixed(str));
109 	}
110 	
111 	/// Convert to string, quoting or escaping spaces if necessary.
112 	string toString()
113 	{
114 		return escapeShellArg(str);
115 	}
116 	
117 	/// Returns the underlying string. Does NOT do any escaping, even if path contains spaces.
118 	string toRawString() const
119 	{
120 		return str;
121 	}
122 
123 	/// Concatenates two paths, with a directory separator in between.
124 	Path opBinary(string op)(Path rhs) if(op=="~")
125 	{
126 		Path newPath;
127 		newPath.str = buildNormalizedPathFixed(this.str, rhs.str);
128 		return newPath;
129 	}
130 	
131 	///ditto
132 	Path opBinary(string op)(string rhs) if(op=="~")
133 	{
134 		Path newPath;
135 		newPath.str = buildNormalizedPathFixed(this.str, rhs);
136 		return newPath;
137 	}
138 	
139 	///ditto
140 	Path opBinaryRight(string op)(string lhs) if(op=="~")
141 	{
142 		Path newPath;
143 		newPath.str = buildNormalizedPathFixed(lhs, this.str);
144 		return newPath;
145 	}
146 	
147 	/// Appends an extension to a path. Naturally, a directory separator
148 	/// is NOT inserted in between.
149 	Path opBinary(string op)(Ext rhs) if(op=="~")
150 	{
151 		Path newPath;
152 		newPath.str = std.path.setExtension(this.str, rhs.str);
153 		return newPath;
154 	}
155 	
156 	/// Appends a path to this one, with a directory separator in between.
157 	Path opOpAssign(string op)(Path rhs) if(op=="~")
158 	{
159 		str = buildNormalizedPathFixed(str, rhs.str);
160 		return this;
161 	}
162 	
163 	///ditto
164 	Path opOpAssign(string op)(string rhs) if(op=="~")
165 	{
166 		str = buildNormalizedPathFixed(str, rhs);
167 		return this;
168 	}
169 	
170 	/// Appends an extension to this path. Naturally, a directory separator
171 	/// is NOT inserted in between.
172 	Path opOpAssign(string op)(Ext rhs) if(op=="~")
173 	{
174 		str = std.path.setExtension(str, rhs.str);
175 		return this;
176 	}
177 	
178 	/// Compare using OS-specific case-sensitivity rules. If you want to force
179 	/// case-sensitive or case-insensistive, then call filenameCmp instead.
180 	int opCmp(ref const Path other) const
181 	{
182 		return std.path.filenameCmp(this.str, other.str);
183 	}
184 
185 	///ditto
186 	int opCmp(Path other) const
187 	{
188 		return std.path.filenameCmp(this.str, other.str);
189 	}
190 
191 	///ditto
192 	int opCmp(string other) const
193 	{
194 		return std.path.filenameCmp(this.str, other);
195 	}
196 
197 	/// Compare using OS-specific case-sensitivity rules. If you want to force
198 	/// case-sensitive or case-insensistive, then call filenameCmp instead.
199 	int opEquals(ref const Path other) const
200 	{
201 		return opCmp(other) == 0;
202 	}
203 
204 	///ditto
205 	int opEquals(Path other) const
206 	{
207 		return opCmp(other) == 0;
208 	}
209 
210 	///ditto
211 	int opEquals(string other) const
212 	{
213 		return opCmp(other) == 0;
214 	}
215 	
216 	/// Convert to bool
217 	T opCast(T)() if(is(T==bool))
218 	{
219 		return !!str;
220 	}
221 	
222 	/// Returns the parent path, according to std.path.dirName.
223 	@property Path up()
224 	{
225 		return this.dirName();
226 	}
227 	
228 	/// Is this path equal to empty string?
229 	@property bool empty()
230 	{
231 		return str == "";
232 	}
233 }
234 
235 /// Convenience aliases
236 alias extOf      = extension;
237 alias stripExt   = stripExtension;   ///ditto
238 alias setExt     = setExtension;     ///ditto
239 alias defaultExt = defaultExtension; ///ditto
240 
241 /// Like buildNormalizedPath, but if the result is the current directory,
242 /// this returns "." instead of "". However, if all the inputs are "", or there
243 /// are no inputs, this still returns "" just like buildNormalizedPath.
244 string buildNormalizedPathFixed(string[] paths...)
245 	@trusted pure nothrow
246 {
247 	if(all!`a is null`(paths))
248 		return null;
249 	
250 	if(all!`a==""`(paths))
251 		return "";
252 	
253 	auto result = std.path.buildNormalizedPath(paths);
254 	return result==""? "." : result;
255 }
256 
257 /// Properly escape arguments containing spaces for the command shell, if necessary.
258 string escapeShellArg(string str)
259 {
260 	if(str.canFind(' '))
261 	{
262 		version(Windows)
263 			return escapeWindowsArgument(str);
264 		else version(Posix)
265 			return escapeShellFileName(str);
266 		else
267 			static assert(0, "This platform not supported.");
268 	}
269 	else
270 		return str;
271 }
272 
273 // -- std.path wrappers to support Path type --------------------
274 
275 /// Just like std.path.baseName, but operates on Path.
276 Path baseName(Path path)
277 	@trusted pure
278 {
279 	return Path( std.path.baseName(path.str) );
280 }
281 
282 ///ditto
283 Path baseName(CaseSensitive cs = CaseSensitive.osDefault)
284 	(Path path, in string suffix)
285 	@safe pure
286 {
287 	return Path( std.path.baseName!cs(path.str, suffix) );
288 }
289 /// Just like std.path.dirName, but operates on Path.
290 Path dirName(Path path)
291 {
292 	return Path( std.path.dirName(path.str) );
293 }
294 
295 /// Just like std.path.rootName, but operates on Path.
296 Path rootName(Path path) @safe pure nothrow
297 {
298 	return Path( std.path.rootName(path.str) );
299 }
300 
301 /// Just like std.path.driveName, but operates on Path.
302 Path driveName(Path path) @safe pure nothrow
303 {
304 	return Path( std.path.driveName(path.str) );
305 }
306 
307 /// Just like std.path.stripDrive, but operates on Path.
308 Path stripDrive(Path path) @safe pure nothrow
309 {
310 	return Path( std.path.stripDrive(path.str) );
311 }
312 
313 /// Just like std.path.extension, but takes a Path and returns an Ext.
314 Ext extension(in Path path) @safe pure nothrow
315 {
316 	return Ext( std.path.extension(path.str) );
317 }
318 
319 /// Just like std.path.stripExtension, but operates on Path.
320 Path stripExtension(Path path) @safe pure nothrow
321 {
322 	return Path( std.path.stripExtension(path.str) );
323 }
324 
325 /// Just like std.path.setExtension, but operates on Path.
326 Path setExtension(Path path, string ext)
327 	@trusted pure nothrow
328 {
329 	return Path( std.path.setExtension(path.str, ext) );
330 }
331 
332 ///ditto
333 Path setExtension(Path path, Ext ext)
334 	@trusted pure nothrow
335 {
336 	return path.setExtension(ext.toString());
337 }
338 
339 /// Just like std.path.defaultExtension, but operates on Path and optionally Ext.
340 Path defaultExtension(Path path, in string ext)
341 	@trusted pure
342 {
343 	return Path( std.path.defaultExtension(path.str, ext) );
344 }
345 
346 ///ditto
347 Path defaultExtension(Path path, Ext ext)
348 	@trusted pure
349 {
350 	return path.defaultExtension(ext.toString());
351 }
352 
353 /// Just like std.path.pathSplitter. Note this returns a range of strings,
354 /// not a range of Path.
355 auto pathSplitter(Path path) @safe pure nothrow
356 {
357 	return std.path.pathSplitter(path.str);
358 }
359 
360 /// Just like std.path.isRooted, but operates on Path.
361 bool isRooted(in Path path) @safe pure nothrow
362 {
363 	return std.path.isRooted(path.str);
364 }
365 
366 /// Just like std.path.isAbsolute, but operates on Path.
367 bool isAbsolute(in Path path) @safe pure nothrow
368 {
369 	return std.path.isAbsolute(path.str);
370 }
371 
372 /// Just like std.path.absolutePath, but operates on Path.
373 Path absolutePath(Path path, lazy string base = getcwd())
374 	@safe pure
375 {
376 	return Path( std.path.absolutePath(path.str, base) );
377 }
378 
379 ///ditto
380 Path absolutePath(Path path, Path base)
381 	@safe pure
382 {
383 	return Path( std.path.absolutePath(path.str, base.str.to!string()) );
384 }
385 
386 /// Just like std.path.relativePath, but operates on Path.
387 Path relativePath(CaseSensitive cs = CaseSensitive.osDefault)
388 	(Path path, lazy string base = getcwd())
389 {
390 	return Path( std.path.relativePath!cs(path.str, base) );
391 }
392 
393 ///ditto
394 Path relativePath(CaseSensitive cs = CaseSensitive.osDefault)
395 	(Path path, Path base)
396 {
397 	return Path( std.path.relativePath!cs(path.str, base.str.to!string()) );
398 }
399 
400 /// Just like std.path.filenameCmp, but operates on Path.
401 int filenameCmp(CaseSensitive cs = CaseSensitive.osDefault)
402 	(Path path, Path filename2)
403 	@safe pure
404 {
405 	return std.path.filenameCmp(path.str, filename2.str);
406 }
407 
408 ///ditto
409 int filenameCmp(CaseSensitive cs = CaseSensitive.osDefault)
410 	(Path path, string filename2)
411 	@safe pure
412 {
413 	return std.path.filenameCmp(path.str, filename2);
414 }
415 
416 ///ditto
417 int filenameCmp(CaseSensitive cs = CaseSensitive.osDefault)
418 	(string path, Path filename2)
419 	@safe pure
420 {
421 	return std.path.filenameCmp(path, filename2.str);
422 }
423 
424 /// Just like std.path.globMatch, but operates on Path.
425 bool globMatch(CaseSensitive cs = CaseSensitive.osDefault)
426 	(Path path, string pattern)
427 	@safe pure nothrow
428 {
429 	return std.path.globMatch!cs(path.str, pattern);
430 }
431 
432 /// Just like std.path.isValidFilename, but operates on Path.
433 bool isValidFilename(in Path path) @safe pure nothrow
434 {
435 	return std.path.isValidFilename(path.str);
436 }
437 
438 /// Just like std.path.isValidPath, but operates on Path.
439 bool isValidPath(in Path path) @safe pure nothrow
440 {
441 	return std.path.isValidPath(path.str);
442 }
443 
444 /// Just like std.path.expandTilde, but operates on Path.
445 Path expandTilde(Path path)
446 {
447 	return Path( std.path.expandTilde(path.str) );
448 }
449 
450 // The unittests in this module mainly check that all the templates compile
451 // correctly and that the appropriate Phobos functions are correctly called.
452 //
453 // A completely thorough testing of the behavior of such functions is
454 // occasionally left to Phobos itself as it is outside the scope of these tests.
455 
456 version(unittest_scriptlike_d)
457 unittest
458 {
459 	import std.stdio : writeln;
460 	writeln("Running Scriptlike unittests: std.path wrappers");
461 	
462 	alias dirSep = dirSeparator;
463 
464 	{
465 		auto e = Ext(".txt");
466 		assert(e != Ext(".dat"));
467 		assert(e == Ext(".txt"));
468 		version(Windows)
469 			assert(e == Ext(".TXT"));
470 		else version(OSX)
471 			assert(e == Ext(".TXT"));
472 		else version(Posix)
473 			assert(e != Ext(".TXT"));
474 		else
475 			static assert(0, "This platform not supported.");
476 		
477 		// Test the other comparison overloads
478 		assert(e != Ext(".dat"));
479 		assert(e == Ext(".txt"));
480 		assert(Ext(".dat") != e);
481 		assert(Ext(".txt") == e);
482 		assert(".dat" != e);
483 		assert(".txt" == e);
484 
485 		assert(Ext("foo"));
486 		assert(Ext(""));
487 		assert(Ext(null).str is null);
488 		assert(!Ext(null));
489 	}
490 
491 	auto p = Path();
492 	assert(p.str == ".");
493 	assert(!p.empty);
494 	
495 	assert(Path("").empty);
496 	
497 	assert(Path("foo"));
498 	assert(Path(""));
499 	assert(Path(null).str is null);
500 	assert(!Path(null));
501 	
502 	version(Windows)
503 		auto testStrings = ["/foo/bar", "/foo/bar/", `\foo\bar`, `\foo\bar\`];
504 	else version(Posix)
505 		auto testStrings = ["/foo/bar", "/foo/bar/"];
506 	else
507 		static assert(0, "This platform not supported.");
508 	
509 	foreach(str; testStrings)
510 	{
511 		writeln("  testing str: ", str);
512 		
513 		p = Path(str);
514 		assert(!p.empty);
515 		assert(p.str == dirSep~"foo"~dirSep~"bar");
516 		
517 		p = Path(str);
518 		assert(p.str == dirSep~"foo"~dirSep~"bar");
519 		assert(p.toRawString() == p.str);
520 		assert(p.toString()    == p.str.to!string());
521 		
522 		assert(p.up.toString() == dirSep~"foo");
523 		assert(p.up.up.toString() == dirSep);
524 
525 		assert((p~"sub").toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"sub");
526 		assert((p~"sub"~"2").toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"sub"~dirSep~"2");
527 		assert((p~Path("sub")).toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"sub");
528 		
529 		version(Windows)
530 			assert((p~"sub dir").toString() == `"`~dirSep~"foo"~dirSep~"bar"~dirSep~"sub dir"~`"`);
531 		else version(Posix)
532 			assert((p~"sub dir").toString() == `'`~dirSep~"foo"~dirSep~"bar"~dirSep~`sub dir'`);
533 		else
534 			static assert(0, "This platform not supported.");
535 
536 		assert(("dir"~p).toString() == dirSep~"foo"~dirSep~"bar");
537 		assert(("dir"~Path(str[1..$])).toString() == "dir"~dirSep~"foo"~dirSep~"bar");
538 		
539 		p ~= "blah";
540 		assert(p.toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"blah");
541 		
542 		p ~= Path("more");
543 		assert(p.toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"blah"~dirSep~"more");
544 		
545 		p ~= "..";
546 		assert(p.toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"blah");
547 		
548 		p ~= Path("..");
549 		assert(p.toString() == dirSep~"foo"~dirSep~"bar");
550 		
551 		p ~= "sub dir";
552 		p ~= "..";
553 		assert(p.toString() == dirSep~"foo"~dirSep~"bar");
554 		
555 		p ~= "filename";
556 		assert((p~Ext(".txt")).toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
557 		assert((p~Ext("txt")).toString()  == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
558 		assert((p~Ext("")).toString()     == dirSep~"foo"~dirSep~"bar"~dirSep~"filename");
559 
560 		p ~= Ext(".ext");
561 		assert(p.toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
562 		assert(p.baseName().toString() == "filename.ext");
563 		assert(p.dirName().toString() == dirSep~"foo"~dirSep~"bar");
564 		assert(p.rootName().toString() == dirSep);
565 		assert(p.driveName().toString() == "");
566 		assert(p.stripDrive().toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
567 		version(Windows)
568 		{
569 			assert(( Path("C:"~p.toRawString()) ).toString() == "C:"~dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
570 			assert(( Path("C:"~p.toRawString()) ).stripDrive().toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
571 		}
572 		assert(p.extension().toString() == ".ext");
573 		assert(p.stripExtension().toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename");
574 		assert(p.setExtension(".txt").toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
575 		assert(p.setExtension("txt").toString()  == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
576 		assert(p.setExtension("").toString()     == dirSep~"foo"~dirSep~"bar"~dirSep~"filename");
577 		assert(p.setExtension(Ext(".txt")).toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
578 		assert(p.setExtension(Ext("txt")).toString()  == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.txt");
579 		assert(p.setExtension(Ext("")).toString()     == dirSep~"foo"~dirSep~"bar"~dirSep~"filename");
580 
581 		assert(p.defaultExtension(".dat").toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
582 		assert(p.stripExtension().defaultExtension(".dat").toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.dat");
583 
584 		assert(equal(p.pathSplitter(), [dirSep, "foo", "bar", "filename.ext"]));
585 
586 		assert(p.isRooted());
587 		version(Windows)
588 			assert(!p.isAbsolute());
589 		else version(Posix)
590 			assert(p.isAbsolute());
591 		else
592 			static assert(0, "This platform not supported.");
593 
594 		assert(!( Path("dir"~p.toRawString()) ).isRooted());
595 		assert(!( Path("dir"~p.toRawString()) ).isAbsolute());
596 		
597 		version(Windows)
598 		{
599 			assert(( Path("dir"~p.toRawString()) ).absolutePath("C:/main").toString() == "C:"~dirSep~"main"~dirSep~"dir"~dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
600 			assert(( Path("C:"~p.toRawString()) ).relativePath("C:/foo").toString() == "bar"~dirSep~"filename.ext");
601 			assert(( Path("C:"~p.toRawString()) ).relativePath("C:/foo/bar").toString() == "filename.ext");
602 		}
603 		else version(Posix)
604 		{
605 			assert(( Path("dir"~p.toRawString()) ).absolutePath("/main").toString() == dirSep~"main"~dirSep~"dir"~dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
606 			assert(p.relativePath("/foo").toString() == "bar"~dirSep~"filename.ext");
607 			assert(p.relativePath("/foo/bar").toString() == "filename.ext");
608 		}
609 		else
610 			static assert(0, "This platform not supported.");
611 
612 		assert(p.filenameCmp(dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext") == 0);
613 		assert(p.filenameCmp(dirSep~"faa"~dirSep~"bat"~dirSep~"filename.ext") != 0);
614 		assert(p.globMatch("*foo*name.ext"));
615 		assert(!p.globMatch("*foo*Bname.ext"));
616 
617 		assert(!p.isValidFilename());
618 		assert(p.baseName().isValidFilename());
619 		assert(p.isValidPath());
620 		
621 		assert(p.expandTilde().toString() == dirSep~"foo"~dirSep~"bar"~dirSep~"filename.ext");
622 		
623 		assert(p != Path("/dir/subdir/filename.ext"));
624 		assert(p == Path("/foo/bar/filename.ext"));
625 		version(Windows)
626 			assert(p == Path("/FOO/BAR/FILENAME.EXT"));
627 		else version(OSX)
628 			assert(p == Path("/FOO/BAR/FILENAME.EXT"));
629 		else version(Posix)
630 			assert(p != Path("/FOO/BAR/FILENAME.EXT"));
631 		else
632 			static assert(0, "This platform not supported.");
633 		
634 		// Test the other comparison overloads
635 		assert(p != Path("/dir/subdir/filename.ext"));
636 		assert(p == Path("/foo/bar/filename.ext"));
637 		assert(Path("/dir/subdir/filename.ext") != p);
638 		assert(Path("/foo/bar/filename.ext")    == p);
639 		assert("/dir/subdir/filename.ext" != p);
640 		assert("/foo/bar/filename.ext"    == p);
641 	}
642 }