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