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 = stripUTF8Bom(cast(string)readFile(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 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 void download(URL url, NativePath filename, uint timeout = 8) 230 { 231 download(url.toString(), filename.toNativeString(), timeout); 232 } 233 /// ditto 234 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 cast(ubyte[])get(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 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)", url, i + 1, retryCount); 323 if (i == retryCount - 1) throw e; 324 else continue; 325 } 326 } 327 catch(CurlException e) { 328 logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount); 329 continue; 330 } 331 } 332 else 333 { 334 try { 335 return download(url); 336 } 337 catch(HTTPStatusException e) { 338 if (e.status == 404) throw e; 339 else { 340 logDebug("Failed to download %s (Attempt %s of %s)", url, i + 1, retryCount); 341 if (i == retryCount - 1) throw e; 342 else continue; 343 } 344 } 345 } 346 } 347 throw new Exception("Failed to download %s".format(url)); 348 } 349 350 /// Returns the current DUB version in semantic version format 351 string getDUBVersion() 352 { 353 import dub.version_; 354 import std.array : split, join; 355 // convert version string to valid SemVer format 356 auto verstr = dubVersion; 357 if (verstr.startsWith("v")) verstr = verstr[1 .. $]; 358 auto parts = verstr.split("-"); 359 if (parts.length >= 3) { 360 // detect GIT commit suffix 361 if (parts[$-1].length == 8 && parts[$-1][1 .. $].isHexNumber() && parts[$-2].isNumber()) 362 verstr = parts[0 .. $-2].join("-") ~ "+" ~ parts[$-2 .. $].join("-"); 363 } 364 return verstr; 365 } 366 367 368 /** 369 Get current executable's path if running as DUB executable, 370 or find a DUB executable if DUB is used as a library. 371 For the latter, the following locations are checked in order: 372 $(UL 373 $(LI current working directory) 374 $(LI same directory as `compilerBinary` (if supplied)) 375 $(LI all components of the `$PATH` variable) 376 ) 377 Params: 378 compilerBinary = optional path to a D compiler executable, used to locate DUB executable 379 Returns: 380 The path to a valid DUB executable 381 Throws: 382 an Exception if no valid DUB executable is found 383 */ 384 public NativePath getDUBExePath(in string compilerBinary=null) 385 { 386 version(DubApplication) { 387 import std.file : thisExePath; 388 return NativePath(thisExePath()); 389 } 390 else { 391 // this must be dub as a library 392 import std.algorithm : filter, map, splitter; 393 import std.array : array; 394 import std.file : exists, getcwd; 395 import std.path : chainPath, dirName; 396 import std.range : chain, only, take; 397 import std.process : environment; 398 399 version(Windows) { 400 enum exeName = "dub.exe"; 401 enum pathSep = ';'; 402 } 403 else { 404 enum exeName = "dub"; 405 enum pathSep = ':'; 406 } 407 408 auto dubLocs = only( 409 getcwd().chainPath(exeName), 410 compilerBinary.dirName.chainPath(exeName), 411 ) 412 .take(compilerBinary.length ? 2 : 1) 413 .chain( 414 environment.get("PATH", "") 415 .splitter(pathSep) 416 .map!(p => p.chainPath(exeName)) 417 ) 418 .filter!exists; 419 420 enforce(!dubLocs.empty, "Could not find DUB executable"); 421 return NativePath(dubLocs.front.array); 422 } 423 } 424 425 426 version(DubUseCurl) { 427 void setupHTTPClient(ref HTTP conn, uint timeout) 428 { 429 static if( is(typeof(&conn.verifyPeer)) ) 430 conn.verifyPeer = false; 431 432 auto proxy = environment.get("http_proxy", null); 433 if (proxy.length) conn.proxy = proxy; 434 435 auto noProxy = environment.get("no_proxy", null); 436 if (noProxy.length) conn.handle.set(CurlOption.noproxy, noProxy); 437 438 conn.handle.set(CurlOption.encoding, ""); 439 if (timeout) { 440 // connection (TLS+TCP) times out after 8s 441 conn.handle.set(CurlOption.connecttimeout, timeout); 442 // transfers time out after 8s below 10 byte/s 443 conn.handle.set(CurlOption.low_speed_limit, 10); 444 conn.handle.set(CurlOption.low_speed_time, timeout); 445 } 446 447 conn.addRequestHeader("User-Agent", "dub/"~getDUBVersion()~" (std.net.curl; +https://github.com/rejectedsoftware/dub)"); 448 449 enum CURL_NETRC_OPTIONAL = 1; 450 conn.handle.set(CurlOption.netrc, CURL_NETRC_OPTIONAL); 451 } 452 } 453 454 string stripUTF8Bom(string str) 455 { 456 if( str.length >= 3 && str[0 .. 3] == [0xEF, 0xBB, 0xBF] ) 457 return str[3 ..$]; 458 return str; 459 } 460 461 private bool isNumber(string str) { 462 foreach (ch; str) 463 switch (ch) { 464 case '0': .. case '9': break; 465 default: return false; 466 } 467 return true; 468 } 469 470 private bool isHexNumber(string str) { 471 foreach (ch; str) 472 switch (ch) { 473 case '0': .. case '9': break; 474 case 'a': .. case 'f': break; 475 case 'A': .. case 'F': break; 476 default: return false; 477 } 478 return true; 479 } 480 481 /** 482 Get the closest match of $(D input) in the $(D array), where $(D distance) 483 is the maximum levenshtein distance allowed between the compared strings. 484 Returns $(D null) if no closest match is found. 485 */ 486 string getClosestMatch(string[] array, string input, size_t distance) 487 { 488 import std.algorithm : countUntil, map, levenshteinDistance; 489 import std.uni : toUpper; 490 491 auto distMap = array.map!(elem => 492 levenshteinDistance!((a, b) => toUpper(a) == toUpper(b))(elem, input)); 493 auto idx = distMap.countUntil!(a => a <= distance); 494 return (idx == -1) ? null : array[idx]; 495 } 496 497 /** 498 Searches for close matches to input in range. R must be a range of strings 499 Note: Sorts the strings range. Use std.range.indexed to avoid this... 500 */ 501 auto fuzzySearch(R)(R strings, string input){ 502 import std.algorithm : levenshteinDistance, schwartzSort, partition3; 503 import std.traits : isSomeString; 504 import std.range : ElementType; 505 506 static assert(isSomeString!(ElementType!R), "Cannot call fuzzy search on non string rang"); 507 immutable threshold = input.length / 4; 508 return strings.partition3!((a, b) => a.length + threshold < b.length)(input)[1] 509 .schwartzSort!(p => levenshteinDistance(input.toUpper, p.toUpper)); 510 } 511 512 /** 513 If T is a bitfield-style enum, this function returns a string range 514 listing the names of all members included in the given value. 515 516 Example: 517 --------- 518 enum Bits { 519 none = 0, 520 a = 1<<0, 521 b = 1<<1, 522 c = 1<<2, 523 a_c = a | c, 524 } 525 526 assert( bitFieldNames(Bits.none).equals(["none"]) ); 527 assert( bitFieldNames(Bits.a).equals(["a"]) ); 528 assert( bitFieldNames(Bits.a_c).equals(["a", "c", "a_c"]) ); 529 --------- 530 */ 531 auto bitFieldNames(T)(T value) if(is(T==enum) && isIntegral!T) 532 { 533 import std.algorithm : filter, map; 534 import std.conv : to; 535 import std.traits : EnumMembers; 536 537 return [ EnumMembers!(T) ] 538 .filter!(member => member==0? value==0 : (value & member) == member) 539 .map!(member => to!string(member)); 540 } 541 542 543 bool isIdentChar(dchar ch) 544 { 545 import std.ascii : isAlphaNum; 546 return isAlphaNum(ch) || ch == '_'; 547 } 548 549 string stripDlangSpecialChars(string s) 550 { 551 import std.array : appender; 552 auto ret = appender!string(); 553 foreach(ch; s) 554 ret.put(isIdentChar(ch) ? ch : '_'); 555 return ret.data; 556 } 557 558 string determineModuleName(BuildSettings settings, NativePath file, NativePath base_path) 559 { 560 import std.algorithm : map; 561 import std.array : array; 562 import std.range : walkLength; 563 564 assert(base_path.absolute); 565 if (!file.absolute) file = base_path ~ file; 566 567 size_t path_skip = 0; 568 foreach (ipath; settings.importPaths.map!(p => NativePath(p))) { 569 if (!ipath.absolute) ipath = base_path ~ ipath; 570 assert(!ipath.empty); 571 if (file.startsWith(ipath) && ipath.bySegment.walkLength > path_skip) 572 path_skip = ipath.bySegment.walkLength; 573 } 574 575 auto mpath = file.bySegment.array[path_skip .. $]; 576 auto ret = appender!string; 577 578 //search for module keyword in file 579 string moduleName = getModuleNameFromFile(file.to!string); 580 581 if(moduleName.length) { 582 assert(moduleName.length > 0, "Wasn't this module name already checked? what"); 583 return moduleName; 584 } 585 586 //create module name from path 587 if (path_skip == 0) 588 { 589 import std.path; 590 ret ~= mpath[$-1].name.baseName(".d"); 591 } 592 else 593 { 594 foreach (i; 0 .. mpath.length) { 595 import std.path; 596 auto p = mpath[i].name; 597 if (p == "package.d") break ; 598 if (ret.data.length > 0) ret ~= "."; 599 if (i+1 < mpath.length) ret ~= p; 600 else ret ~= p.baseName(".d"); 601 } 602 } 603 604 assert(ret.data.length > 0, "A module name was expected to be computed, and none was."); 605 return ret.data; 606 } 607 608 /** 609 * Search for module keyword in D Code 610 */ 611 string getModuleNameFromContent(string content) { 612 import std.regex; 613 import std.string; 614 615 content = content.strip; 616 if (!content.length) return null; 617 618 static bool regex_initialized = false; 619 static Regex!char comments_pattern, module_pattern; 620 621 if (!regex_initialized) { 622 comments_pattern = regex(`//[^\r\n]*\r?\n?|/\*.*?\*/|/\+.*\+/`, "gs"); 623 module_pattern = regex(`module\s+([\w\.]+)\s*;`, "g"); 624 regex_initialized = true; 625 } 626 627 content = replaceAll(content, comments_pattern, " "); 628 auto result = matchFirst(content, module_pattern); 629 630 if (!result.empty) return result[1]; 631 632 return null; 633 } 634 635 unittest { 636 assert(getModuleNameFromContent("") == ""); 637 assert(getModuleNameFromContent("module myPackage.myModule;") == "myPackage.myModule"); 638 assert(getModuleNameFromContent("module \t\n myPackage.myModule \t\r\n;") == "myPackage.myModule"); 639 assert(getModuleNameFromContent("// foo\nmodule bar;") == "bar"); 640 assert(getModuleNameFromContent("/*\nfoo\n*/\nmodule bar;") == "bar"); 641 assert(getModuleNameFromContent("/+\nfoo\n+/\nmodule bar;") == "bar"); 642 assert(getModuleNameFromContent("/***\nfoo\n***/\nmodule bar;") == "bar"); 643 assert(getModuleNameFromContent("/+++\nfoo\n+++/\nmodule bar;") == "bar"); 644 assert(getModuleNameFromContent("// module foo;\nmodule bar;") == "bar"); 645 assert(getModuleNameFromContent("/* module foo; */\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; // module foo;") == "bar"); 649 assert(getModuleNameFromContent("// module foo;\nmodule// module foo;\nbar//module foo;\n;// module foo;") == "bar"); 650 assert(getModuleNameFromContent("/* module foo; */\nmodule/*module foo;*/bar/*module foo;*/;") == "bar", getModuleNameFromContent("/* module foo; */\nmodule/*module foo;*/bar/*module foo;*/;")); 651 assert(getModuleNameFromContent("/+ /+ module foo; +/ module foo; +/ module bar;") == "bar"); 652 //assert(getModuleNameFromContent("/+ /+ module foo; +/ module foo; +/ module bar/++/;") == "bar"); // nested comments require a context-free parser! 653 assert(getModuleNameFromContent("/*\nmodule sometest;\n*/\n\nmodule fakemath;\n") == "fakemath"); 654 } 655 656 /** 657 * Search for module keyword in file 658 */ 659 string getModuleNameFromFile(string filePath) { 660 if (!filePath.exists) 661 { 662 return null; 663 } 664 string fileContent = filePath.readText; 665 666 logDiagnostic("Get module name from path: %s", filePath); 667 return getModuleNameFromContent(fileContent); 668 } 669 670 /** 671 * Compare two instances of the same type for equality, 672 * providing a rich error message on failure. 673 * 674 * This function will recurse into composite types (struct, AA, arrays) 675 * and compare element / member wise, taking opEquals into account, 676 * to provide the most accurate reason why comparison failed. 677 */ 678 void deepCompare (T) ( 679 in T result, in T expected, string file = __FILE__, size_t line = __LINE__) 680 { 681 deepCompareImpl!T(result, expected, T.stringof, file, line); 682 } 683 684 void deepCompareImpl (T) ( 685 in T result, in T expected, string path, string file, size_t line) 686 { 687 static if (is(T == struct) && !is(typeof(T.init.opEquals(T.init)) : bool)) 688 { 689 static foreach (idx; 0 .. T.tupleof.length) 690 deepCompareImpl(result.tupleof[idx], expected.tupleof[idx], 691 format("%s.%s", path, __traits(identifier, T.tupleof[idx])), 692 file, line); 693 } 694 else static if (is(T : KeyT[ValueT], KeyT, ValueT)) 695 { 696 if (result.length != expected.length) 697 throw new Exception( 698 format("%s: AA has different number of entries (%s != %s): %s != %s", 699 path, result.length, expected.length, result, expected), 700 file, line); 701 foreach (key, value; expected) 702 { 703 if (auto ptr = key in result) 704 deepCompareImpl(*ptr, value, format("%s[%s]", path, key), file, line); 705 else 706 throw new Exception( 707 format("Expected key %s[%s] not present in result. %s != %s", 708 path, key, result, expected), file, line); 709 } 710 } 711 else if (result != expected) { 712 static if (is(T == struct) && is(typeof(T.init.opEquals(T.init)) : bool)) 713 path ~= ".opEquals"; 714 throw new Exception( 715 format("%s: result != expected: %s != %s", path, result, expected), 716 file, line); 717 } 718 }