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 }