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 }