1 /**
2 	...
3 
4 	Copyright: © 2012 Matthias Dondorff
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Matthias Dondorff
7 */
8 module dub.internal.utils;
9 
10 import dub.internal.vibecompat.core.file;
11 import dub.internal.vibecompat.data.json;
12 import dub.internal.vibecompat.inet.url;
13 import dub.compilers.buildsettings : BuildSettings;
14 import dub.version_;
15 import dub.internal.logging;
16 
17 import core.time : Duration;
18 import std.algorithm : canFind, startsWith;
19 import std.array : appender, array;
20 import std.conv : to;
21 import std.exception : enforce;
22 import std.file;
23 import std.format;
24 import std.string : format;
25 import std.process;
26 import std.traits : isIntegral;
27 version(DubUseCurl)
28 {
29 	import std.net.curl;
30 	public import std.net.curl : HTTPStatusException;
31 }
32 
33 
34 private NativePath[] temporary_files;
35 
36 NativePath getTempDir()
37 {
38 	return NativePath(std.file.tempDir());
39 }
40 
41 NativePath getTempFile(string prefix, string extension = null)
42 {
43 	import std.uuid : randomUUID;
44 	import std.array: replace;
45 
46 	string fileName = prefix ~ "-" ~ randomUUID.toString() ~ extension;
47 
48 	if (extension !is null && extension == ".d")
49 		fileName = fileName.replace("-", "_");
50 
51 	auto path = getTempDir() ~ fileName;
52 	temporary_files ~= path;
53 	return path;
54 }
55 
56 /**
57  * Obtain a lock for a file at the given path.
58  *
59  * If the file cannot be locked within the given duration,
60  * an exception is thrown. The file will be created if it does not yet exist.
61  * Deleting the file is not safe as another process could create a new file
62  * with the same name.
63  * The returned lock will get unlocked upon destruction.
64  *
65  * Params:
66  *   path = path to file that gets locked
67  *   timeout = duration after which locking failed
68  *
69  * Returns:
70  *   The locked file or an Exception on timeout.
71  */
72 auto lockFile(string path, Duration timeout)
73 {
74 	import core.thread : Thread;
75 	import std.datetime, std.stdio : File;
76 	import std.algorithm : move;
77 
78 	// Just a wrapper to hide (and destruct) the locked File.
79 	static struct LockFile
80 	{
81 		// The Lock can't be unlinked as someone could try to lock an already
82 		// opened fd while a new file with the same name gets created.
83 		// Exclusive filesystem locks (O_EXCL, mkdir) could be deleted but
84 		// aren't automatically freed when a process terminates, see #1149.
85 		private File f;
86 	}
87 
88 	auto file = File(path, "w");
89 	auto t0 = Clock.currTime();
90 	auto dur = 1.msecs;
91 	while (true)
92 	{
93 		if (file.tryLock())
94 			return LockFile(move(file));
95 		enforce(Clock.currTime() - t0 < timeout, "Failed to lock '"~path~"'.");
96 		if (dur < 1024.msecs) // exponentially increase sleep time
97 			dur *= 2;
98 		Thread.sleep(dur);
99 	}
100 }
101 
102 static ~this()
103 {
104 	foreach (path; temporary_files)
105 	{
106 		auto spath = path.toNativeString();
107 		if (spath.exists)
108 			std.file.remove(spath);
109 	}
110 }
111 
112 bool isWritableDir(NativePath p, bool create_if_missing = false)
113 {
114 	import std.random;
115 	auto fname = p ~ format("__dub_write_test_%08X", uniform(0, uint.max));
116 	if (create_if_missing)
117 		ensureDirectory(p);
118 	try writeFile(fname, "Canary");
119 	catch (Exception) return false;
120 	remove(fname.toNativeString());
121 	return true;
122 }
123 
124 Json jsonFromFile(NativePath file, bool silent_fail = false) {
125 	if( silent_fail && !existsFile(file) ) return Json.emptyObject;
126 	auto text = stripUTF8Bom(cast(string)readFile(file));
127 	return parseJsonString(text, file.toNativeString());
128 }
129 
130 /**
131 	Read package info file content from archive.
132 	File needs to be in root folder or in first
133 	sub folder.
134 
135 	Params:
136 		zip = path to archive file
137 		fileName = Package file name
138 	Returns:
139 		package file content.
140 */
141 string packageInfoFileFromZip(NativePath zip, out string fileName) {
142 	import std.zip : ZipArchive, ArchiveMember;
143 	import dub.package_ : packageInfoFiles;
144 
145 	auto b = readFile(zip);
146 	auto archive = new ZipArchive(b);
147 	alias PSegment = typeof (NativePath.init.head);
148 	foreach (ArchiveMember am; archive.directory) {
149 		auto path = NativePath(am.name).bySegment.array;
150 		foreach (fil; packageInfoFiles) {
151 			if ((path.length == 1 && path[0] == fil.filename) || (path.length == 2 && path[$-1].name == fil.filename)) {
152 				fileName = fil.filename;
153 				return stripUTF8Bom(cast(string) archive.expand(archive.directory[am.name]));
154 			}
155 		}
156 	}
157 	throw new Exception("No package descriptor found");
158 }
159 
160 void writeJsonFile(NativePath path, Json json)
161 {
162 	auto app = appender!string();
163 	app.writePrettyJsonString(json);
164 	writeFile(path, app.data);
165 }
166 
167 /// Performs a write->delete->rename sequence to atomically "overwrite" the destination file
168 void atomicWriteJsonFile(NativePath path, Json json)
169 {
170 	import std.random : uniform;
171 	auto tmppath = path.parentPath ~ format("%s.%s.tmp", path.head, uniform(0, int.max));
172 	auto app = appender!string();
173 	app.writePrettyJsonString(json);
174 	writeFile(tmppath, app.data);
175 	if (existsFile(path)) removeFile(path);
176 	moveFile(tmppath, path);
177 }
178 
179 void runCommand(string command, string[string] env = null, string workDir = null)
180 {
181 	runCommands((&command)[0 .. 1], env, workDir);
182 }
183 
184 void runCommands(in string[] commands, string[string] env = null, string workDir = null)
185 {
186 	import std.stdio : stdin, stdout, stderr, File;
187 
188 	version(Windows) enum nullFile = "NUL";
189 	else version(Posix) enum nullFile = "/dev/null";
190 	else static assert(0);
191 
192 	auto childStdout = stdout;
193 	auto childStderr = stderr;
194 	auto config = Config.retainStdout | Config.retainStderr;
195 
196 	// Disable child's stdout/stderr depending on LogLevel
197 	auto logLevel = getLogLevel();
198 	if(logLevel >= LogLevel.warn)
199 		childStdout = File(nullFile, "w");
200 	if(logLevel >= LogLevel.none)
201 		childStderr = File(nullFile, "w");
202 
203 	foreach(cmd; commands){
204 		logDiagnostic("Running %s", cmd);
205 		Pid pid;
206 		pid = spawnShell(cmd, stdin, childStdout, childStderr, env, config, workDir);
207 		auto exitcode = pid.wait();
208 		enforce(exitcode == 0, "Command failed with exit code "
209 			~ to!string(exitcode) ~ ": " ~ cmd);
210 	}
211 }
212 
213 version (Have_vibe_d_http)
214 	public import vibe.http.common : HTTPStatusException;
215 
216 /**
217 	Downloads a file from the specified URL.
218 
219 	Any redirects will be followed until the actual file resource is reached or if the redirection
220 	limit of 10 is reached. Note that only HTTP(S) is currently supported.
221 
222 	The download times out if a connection cannot be established within
223 	`timeout` ms, or if the average transfer rate drops below 10 bytes / s for
224 	more than `timeout` seconds.  Pass `0` as `timeout` to disable both timeout
225 	mechanisms.
226 
227 	Note: Timeouts are only implemented when curl is used (DubUseCurl).
228 */
229 void download(string url, string filename, uint timeout = 8)
230 {
231 	version(DubUseCurl) {
232 		auto conn = HTTP();
233 		setupHTTPClient(conn, timeout);
234 		logDebug("Storing %s...", url);
235 		std.net.curl.download(url, filename, conn);
236 		// workaround https://issues.dlang.org/show_bug.cgi?id=18318
237 		auto sl = conn.statusLine;
238 		logDebug("Download %s %s", url, sl);
239 		if (sl.code / 100 != 2)
240 			throw new HTTPStatusException(sl.code,
241 				"Downloading %s failed with %d (%s).".format(url, sl.code, sl.reason));
242 	} else version (Have_vibe_d_http) {
243 		import vibe.inet.urltransfer;
244 		vibe.inet.urltransfer.download(url, filename);
245 	} else assert(false);
246 }
247 /// ditto
248 void download(URL url, NativePath filename, uint timeout = 8)
249 {
250 	download(url.toString(), filename.toNativeString(), timeout);
251 }
252 /// ditto
253 ubyte[] download(string url, uint timeout = 8)
254 {
255 	version(DubUseCurl) {
256 		auto conn = HTTP();
257 		setupHTTPClient(conn, timeout);
258 		logDebug("Getting %s...", url);
259 		return cast(ubyte[])get(url, conn);
260 	} else version (Have_vibe_d_http) {
261 		import vibe.inet.urltransfer;
262 		import vibe.stream.operations;
263 		ubyte[] ret;
264 		vibe.inet.urltransfer.download(url, (scope input) { ret = input.readAll(); });
265 		return ret;
266 	} else assert(false);
267 }
268 /// ditto
269 ubyte[] download(URL url, uint timeout = 8)
270 {
271 	return download(url.toString(), timeout);
272 }
273 
274 /**
275 	Downloads a file from the specified URL with retry logic.
276 
277 	Downloads a file from the specified URL with up to n tries on failure
278 	Throws: `Exception` if the download failed or `HTTPStatusException` after the nth retry or
279 	on "unrecoverable failures" such as 404 not found
280 	Otherwise might throw anything else that `download` throws.
281 	See_Also: download
282 
283 	The download times out if a connection cannot be established within
284 	`timeout` ms, or if the average transfer rate drops below 10 bytes / s for
285 	more than `timeout` seconds.  Pass `0` as `timeout` to disable both timeout
286 	mechanisms.
287 
288 	Note: Timeouts are only implemented when curl is used (DubUseCurl).
289 **/
290 void retryDownload(URL url, NativePath filename, size_t retryCount = 3, uint timeout = 8)
291 {
292 	foreach(i; 0..retryCount) {
293 		version(DubUseCurl) {
294 			try {
295 				download(url, filename, timeout);
296 				return;
297 			}
298 			catch(HTTPStatusException e) {
299 				if (e.status == 404) throw e;
300 				else {
301 					logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
302 					if (i == retryCount - 1) throw e;
303 					else continue;
304 				}
305 			}
306 			catch(CurlException e) {
307 				logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
308 				continue;
309 			}
310 		}
311 		else
312 		{
313 			try {
314 				download(url, filename);
315 				return;
316 			}
317 			catch(HTTPStatusException e) {
318 				if (e.status == 404) throw e;
319 				else {
320 					logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
321 					if (i == retryCount - 1) throw e;
322 					else continue;
323 				}
324 			}
325 		}
326 	}
327 	throw new Exception("Failed to download %s".format(url));
328 }
329 
330 ///ditto
331 ubyte[] retryDownload(URL url, size_t retryCount = 3, uint timeout = 8)
332 {
333 	foreach(i; 0..retryCount) {
334 		version(DubUseCurl) {
335 			try {
336 				return download(url, timeout);
337 			}
338 			catch(HTTPStatusException e) {
339 				if (e.status == 404) throw e;
340 				else {
341 					logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
342 					if (i == retryCount - 1) throw e;
343 					else continue;
344 				}
345 			}
346 			catch(CurlException e) {
347 				logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
348 				continue;
349 			}
350 		}
351 		else
352 		{
353 			try {
354 				return download(url);
355 			}
356 			catch(HTTPStatusException e) {
357 				if (e.status == 404) throw e;
358 				else {
359 					logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount);
360 					if (i == retryCount - 1) throw e;
361 					else continue;
362 				}
363 			}
364 		}
365 	}
366 	throw new Exception("Failed to download %s".format(url));
367 }
368 
369 /// Returns the current DUB version in semantic version format
370 string getDUBVersion()
371 {
372 	import dub.version_;
373 	import std.array : split, join;
374 	// convert version string to valid SemVer format
375 	auto verstr = dubVersion;
376 	if (verstr.startsWith("v")) verstr = verstr[1 .. $];
377 	auto parts = verstr.split("-");
378 	if (parts.length >= 3) {
379 		// detect GIT commit suffix
380 		if (parts[$-1].length == 8 && parts[$-1][1 .. $].isHexNumber() && parts[$-2].isNumber())
381 			verstr = parts[0 .. $-2].join("-") ~ "+" ~ parts[$-2 .. $].join("-");
382 	}
383 	return verstr;
384 }
385 
386 
387 /**
388 	Get current executable's path if running as DUB executable,
389 	or find a DUB executable if DUB is used as a library.
390 	For the latter, the following locations are checked in order:
391 	$(UL
392 		$(LI current working directory)
393 		$(LI same directory as `compilerBinary` (if supplied))
394 		$(LI all components of the `$PATH` variable)
395 	)
396 	Params:
397 		compilerBinary = optional path to a D compiler executable, used to locate DUB executable
398 	Returns:
399 		The path to a valid DUB executable
400 	Throws:
401 		an Exception if no valid DUB executable is found
402 */
403 public string getDUBExePath(in string compilerBinary=null)
404 {
405 	version(DubApplication) {
406 		import std.file : thisExePath;
407 		return thisExePath();
408 	}
409 	else {
410 		// this must be dub as a library
411 		import std.algorithm : filter, map, splitter;
412 		import std.array : array;
413 		import std.file : exists, getcwd;
414 		import std.path : chainPath, dirName;
415 		import std.range : chain, only, take;
416 		import std.process : environment;
417 
418 		version(Windows) {
419 			enum exeName = "dub.exe";
420 			enum pathSep = ';';
421 		}
422 		else {
423 			enum exeName = "dub";
424 			enum pathSep = ':';
425 		}
426 
427 		auto dubLocs = only(
428 			getcwd().chainPath(exeName),
429 			compilerBinary.dirName.chainPath(exeName),
430 		)
431 		.take(compilerBinary.length ? 2 : 1)
432 		.chain(
433 			environment.get("PATH", "")
434 				.splitter(pathSep)
435 				.map!(p => p.chainPath(exeName))
436 		)
437 		.filter!exists;
438 
439 		enforce(!dubLocs.empty, "Could not find DUB executable");
440 		return dubLocs.front.array;
441 	}
442 }
443 
444 
445 version(DubUseCurl) {
446 	void setupHTTPClient(ref HTTP conn, uint timeout)
447 	{
448 		static if( is(typeof(&conn.verifyPeer)) )
449 			conn.verifyPeer = false;
450 
451 		auto proxy = environment.get("http_proxy", null);
452 		if (proxy.length) conn.proxy = proxy;
453 
454 		auto noProxy = environment.get("no_proxy", null);
455 		if (noProxy.length) conn.handle.set(CurlOption.noproxy, noProxy);
456 
457 		conn.handle.set(CurlOption.encoding, "");
458 		if (timeout) {
459 			// connection (TLS+TCP) times out after 8s
460 			conn.handle.set(CurlOption.connecttimeout, timeout);
461 			// transfers time out after 8s below 10 byte/s
462 			conn.handle.set(CurlOption.low_speed_limit, 10);
463 			conn.handle.set(CurlOption.low_speed_time, timeout);
464 		}
465 
466 		conn.addRequestHeader("User-Agent", "dub/"~getDUBVersion()~" (std.net.curl; +https://github.com/rejectedsoftware/dub)");
467 
468 		enum CURL_NETRC_OPTIONAL = 1;
469 		conn.handle.set(CurlOption.netrc, CURL_NETRC_OPTIONAL);
470 	}
471 }
472 
473 string stripUTF8Bom(string str)
474 {
475 	if( str.length >= 3 && str[0 .. 3] == [0xEF, 0xBB, 0xBF] )
476 		return str[3 ..$];
477 	return str;
478 }
479 
480 private bool isNumber(string str) {
481 	foreach (ch; str)
482 		switch (ch) {
483 			case '0': .. case '9': break;
484 			default: return false;
485 		}
486 	return true;
487 }
488 
489 private bool isHexNumber(string str) {
490 	foreach (ch; str)
491 		switch (ch) {
492 			case '0': .. case '9': break;
493 			case 'a': .. case 'f': break;
494 			case 'A': .. case 'F': break;
495 			default: return false;
496 		}
497 	return true;
498 }
499 
500 /**
501 	Get the closest match of $(D input) in the $(D array), where $(D distance)
502 	is the maximum levenshtein distance allowed between the compared strings.
503 	Returns $(D null) if no closest match is found.
504 */
505 string getClosestMatch(string[] array, string input, size_t distance)
506 {
507 	import std.algorithm : countUntil, map, levenshteinDistance;
508 	import std.uni : toUpper;
509 
510 	auto distMap = array.map!(elem =>
511 		levenshteinDistance!((a, b) => toUpper(a) == toUpper(b))(elem, input));
512 	auto idx = distMap.countUntil!(a => a <= distance);
513 	return (idx == -1) ? null : array[idx];
514 }
515 
516 /**
517 	Searches for close matches to input in range. R must be a range of strings
518 	Note: Sorts the strings range. Use std.range.indexed to avoid this...
519   */
520 auto fuzzySearch(R)(R strings, string input){
521 	import std.algorithm : levenshteinDistance, schwartzSort, partition3;
522 	import std.traits : isSomeString;
523 	import std.range : ElementType;
524 
525 	static assert(isSomeString!(ElementType!R), "Cannot call fuzzy search on non string rang");
526 	immutable threshold = input.length / 4;
527 	return strings.partition3!((a, b) => a.length + threshold < b.length)(input)[1]
528 			.schwartzSort!(p => levenshteinDistance(input.toUpper, p.toUpper));
529 }
530 
531 /**
532 	If T is a bitfield-style enum, this function returns a string range
533 	listing the names of all members included in the given value.
534 
535 	Example:
536 	---------
537 	enum Bits {
538 		none = 0,
539 		a = 1<<0,
540 		b = 1<<1,
541 		c = 1<<2,
542 		a_c = a | c,
543 	}
544 
545 	assert( bitFieldNames(Bits.none).equals(["none"]) );
546 	assert( bitFieldNames(Bits.a).equals(["a"]) );
547 	assert( bitFieldNames(Bits.a_c).equals(["a", "c", "a_c"]) );
548 	---------
549   */
550 auto bitFieldNames(T)(T value) if(is(T==enum) && isIntegral!T)
551 {
552 	import std.algorithm : filter, map;
553 	import std.conv : to;
554 	import std.traits : EnumMembers;
555 
556 	return [ EnumMembers!(T) ]
557 		.filter!(member => member==0? value==0 : (value & member) == member)
558 		.map!(member => to!string(member));
559 }
560 
561 
562 bool isIdentChar(dchar ch)
563 {
564 	import std.ascii : isAlphaNum;
565 	return isAlphaNum(ch) || ch == '_';
566 }
567 
568 string stripDlangSpecialChars(string s)
569 {
570 	import std.array : appender;
571 	auto ret = appender!string();
572 	foreach(ch; s)
573 		ret.put(isIdentChar(ch) ? ch : '_');
574 	return ret.data;
575 }
576 
577 string determineModuleName(BuildSettings settings, NativePath file, NativePath base_path)
578 {
579 	import std.algorithm : map;
580 	import std.array : array;
581 	import std.range : walkLength;
582 
583 	assert(base_path.absolute);
584 	if (!file.absolute) file = base_path ~ file;
585 
586 	size_t path_skip = 0;
587 	foreach (ipath; settings.importPaths.map!(p => NativePath(p))) {
588 		if (!ipath.absolute) ipath = base_path ~ ipath;
589 		assert(!ipath.empty);
590 		if (file.startsWith(ipath) && ipath.bySegment.walkLength > path_skip)
591 			path_skip = ipath.bySegment.walkLength;
592 	}
593 
594 	auto mpath = file.bySegment.array[path_skip .. $];
595 	auto ret = appender!string;
596 
597 	//search for module keyword in file
598 	string moduleName = getModuleNameFromFile(file.to!string);
599 
600 	if(moduleName.length) {
601 		assert(moduleName.length > 0, "Wasn't this module name already checked? what");
602 		return moduleName;
603 	}
604 
605 	//create module name from path
606 	if (path_skip == 0)
607 	{
608 		import std.path;
609 		ret ~= mpath[$-1].name.baseName(".d");
610 	}
611 	else
612 	{
613 		foreach (i; 0 .. mpath.length) {
614 			import std.path;
615 			auto p = mpath[i].name;
616 			if (p == "package.d") break ;
617 			if (ret.data.length > 0) ret ~= ".";
618 			if (i+1 < mpath.length) ret ~= p;
619 			else ret ~= p.baseName(".d");
620 		}
621 	}
622 
623 	assert(ret.data.length > 0, "A module name was expected to be computed, and none was.");
624 	return ret.data;
625 }
626 
627 /**
628  * Search for module keyword in D Code
629  */
630 string getModuleNameFromContent(string content) {
631 	import std.regex;
632 	import std.string;
633 
634 	content = content.strip;
635 	if (!content.length) return null;
636 
637 	static bool regex_initialized = false;
638 	static Regex!char comments_pattern, module_pattern;
639 
640 	if (!regex_initialized) {
641 		comments_pattern = regex(`//[^\r\n]*\r?\n?|/\*.*?\*/|/\+.*\+/`, "gs");
642 		module_pattern = regex(`module\s+([\w\.]+)\s*;`, "g");
643 		regex_initialized = true;
644 	}
645 
646 	content = replaceAll(content, comments_pattern, " ");
647 	auto result = matchFirst(content, module_pattern);
648 
649 	if (!result.empty) return result[1];
650 
651 	return null;
652 }
653 
654 unittest {
655 	assert(getModuleNameFromContent("") == "");
656 	assert(getModuleNameFromContent("module myPackage.myModule;") == "myPackage.myModule");
657 	assert(getModuleNameFromContent("module \t\n myPackage.myModule \t\r\n;") == "myPackage.myModule");
658 	assert(getModuleNameFromContent("// foo\nmodule bar;") == "bar");
659 	assert(getModuleNameFromContent("/*\nfoo\n*/\nmodule bar;") == "bar");
660 	assert(getModuleNameFromContent("/+\nfoo\n+/\nmodule bar;") == "bar");
661 	assert(getModuleNameFromContent("/***\nfoo\n***/\nmodule bar;") == "bar");
662 	assert(getModuleNameFromContent("/+++\nfoo\n+++/\nmodule bar;") == "bar");
663 	assert(getModuleNameFromContent("// module foo;\nmodule bar;") == "bar");
664 	assert(getModuleNameFromContent("/* module foo; */\nmodule bar;") == "bar");
665 	assert(getModuleNameFromContent("/+ module foo; +/\nmodule bar;") == "bar");
666 	assert(getModuleNameFromContent("/+ /+ module foo; +/ +/\nmodule bar;") == "bar");
667 	assert(getModuleNameFromContent("// module foo;\nmodule bar; // module foo;") == "bar");
668 	assert(getModuleNameFromContent("// module foo;\nmodule// module foo;\nbar//module foo;\n;// module foo;") == "bar");
669 	assert(getModuleNameFromContent("/* module foo; */\nmodule/*module foo;*/bar/*module foo;*/;") == "bar", getModuleNameFromContent("/* module foo; */\nmodule/*module foo;*/bar/*module foo;*/;"));
670 	assert(getModuleNameFromContent("/+ /+ module foo; +/ module foo; +/ module bar;") == "bar");
671 	//assert(getModuleNameFromContent("/+ /+ module foo; +/ module foo; +/ module bar/++/;") == "bar"); // nested comments require a context-free parser!
672 	assert(getModuleNameFromContent("/*\nmodule sometest;\n*/\n\nmodule fakemath;\n") == "fakemath");
673 }
674 
675 /**
676  * Search for module keyword in file
677  */
678 string getModuleNameFromFile(string filePath) {
679 	if (!filePath.exists)
680 	{
681 		return null;
682 	}
683 	string fileContent = filePath.readText;
684 
685 	logDiagnostic("Get module name from path: %s", filePath);
686 	return getModuleNameFromContent(fileContent);
687 }
688 
689 /**
690  * Compare two instances of the same type for equality,
691  * providing a rich error message on failure.
692  *
693  * This function will recurse into composite types (struct, AA, arrays)
694  * and compare element / member wise, taking opEquals into account,
695  * to provide the most accurate reason why comparison failed.
696  */
697 void deepCompare (T) (
698 	in T result, in T expected, string file = __FILE__, size_t line = __LINE__)
699 {
700 	deepCompareImpl!T(result, expected, T.stringof, file, line);
701 }
702 
703 void deepCompareImpl (T) (
704 	in T result, in T expected, string path, string file, size_t line)
705 {
706 	static if (is(T == struct) && !is(typeof(T.init.opEquals(T.init)) : bool))
707 	{
708 		static foreach (idx; 0 .. T.tupleof.length)
709 			deepCompareImpl(result.tupleof[idx], expected.tupleof[idx],
710 							format("%s.%s", path, __traits(identifier, T.tupleof[idx])),
711 							file, line);
712 	}
713 	else static if (is(T : KeyT[ValueT], KeyT, ValueT))
714 	{
715 		if (result.length != expected.length)
716 			throw new Exception(
717 				format("%s: AA has different number of entries (%s != %s): %s != %s",
718 					   path, result.length, expected.length, result, expected),
719 				file, line);
720 		foreach (key, value; expected)
721 		{
722 			if (auto ptr = key in result)
723 				deepCompareImpl(*ptr, value, format("%s[%s]", path, key), file, line);
724 			else
725 				throw new Exception(
726 					format("Expected key %s[%s] not present in result. %s != %s",
727 						   path, key, result, expected), file, line);
728 		}
729 	}
730 	else if (result != expected) {
731 		static if (is(T == struct) && is(typeof(T.init.opEquals(T.init)) : bool))
732 			path ~= ".opEquals";
733 		throw new Exception(
734 			format("%s: result != expected: %s != %s", path, result, expected),
735 			file, line);
736 	}
737 }