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