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 }