1 module workspaced.com.dcd;
2 
3 import std.file : tempDir;
4 
5 import core.thread;
6 import std.algorithm;
7 import std.ascii;
8 import std.conv;
9 import std.datetime;
10 import std.experimental.logger;
11 import std.json;
12 import std.path;
13 import std.process;
14 import std.random;
15 import std.stdio;
16 import std.string;
17 
18 import painlessjson;
19 
20 import workspaced.api;
21 import workspaced.helpers;
22 
23 version (OSX) version = haveUnixSockets;
24 version (linux) version = haveUnixSockets;
25 version (BSD) version = haveUnixSockets;
26 version (FreeBSD) version = haveUnixSockets;
27 
28 @component("dcd")
29 class DCDComponent : ComponentWrapper
30 {
31 	mixin DefaultComponentWrapper;
32 
33 	enum latestKnownVersion = [0, 11, 1];
34 	void load()
35 	{
36 		string clientPath = this.clientPath;
37 		string serverPath = this.serverPath;
38 
39 		installedVersion = clientPath.getVersionAndFixPath;
40 		string clientPathInfo = clientPath != "dcd-client" ? "(" ~ clientPath ~ ") " : "";
41 		trace("Detected dcd-client ", clientPathInfo, installedVersion);
42 
43 		string serverInstalledVersion = serverPath.getVersionAndFixPath;
44 		string serverPathInfo = serverPath != "dcd-server" ? "(" ~ serverPath ~ ") " : "";
45 		trace("Detected dcd-server ", serverPathInfo, serverInstalledVersion);
46 
47 		if (serverInstalledVersion != installedVersion)
48 			throw new Exception("client & server version mismatch");
49 
50 		config.set("dcd", "clientPath", clientPath);
51 		config.set("dcd", "serverPath", serverPath);
52 
53 		assert(this.clientPath == clientPath);
54 		assert(this.serverPath == serverPath);
55 
56 		version (haveUnixSockets)
57 			hasUnixDomainSockets = supportsUnixDomainSockets(installedVersion);
58 
59 		//dfmt off
60 		if (isOutdated)
61 			workspaced.broadcast(refInstance, JSONValue([
62 				"type": JSONValue("outdated"),
63 				"component": JSONValue("dcd")
64 			]));
65 		//dfmt on
66 		supportsFullOutput = rawExec([clientPath, "--help"]).output.canFind("--extended");
67 	}
68 
69 	/// Returns: true if DCD version is less than latestKnownVersion or if server and client mismatch or if it doesn't exist.
70 	bool isOutdated()
71 	{
72 		if (!installedVersion)
73 		{
74 			string clientPath = this.clientPath;
75 			string serverPath = this.serverPath;
76 
77 			try
78 			{
79 				installedVersion = clientPath.getVersionAndFixPath;
80 				if (serverPath.getVersionAndFixPath != installedVersion)
81 					return true;
82 			}
83 			catch (ProcessException)
84 			{
85 				return true;
86 			}
87 		}
88 		return !checkVersion(installedVersion, latestKnownVersion);
89 	}
90 
91 	/// Returns: the current detected installed version of dcd-client.
92 	string clientInstalledVersion() @property const
93 	{
94 		return installedVersion;
95 	}
96 
97 	private auto serverThreads()
98 	{
99 		return threads(1, 2);
100 	}
101 
102 	/// This stops the dcd-server instance safely and waits for it to exit
103 	override void shutdown(bool dtor = false)
104 	{
105 		stopServerSync();
106 		if (!dtor && _threads)
107 			serverThreads.finish();
108 	}
109 
110 	/// This will start the dcd-server and load import paths from the current provider
111 	void setupServer(string[] additionalImports = [], bool quietServer = false)
112 	{
113 		startServer(importPaths ~ importFiles ~ additionalImports, quietServer);
114 	}
115 
116 	/// This will start the dcd-server
117 	void startServer(string[] additionalImports = [], bool quietServer = false)
118 	{
119 		if (isPortRunning(port))
120 			throw new Exception("Already running dcd on port " ~ port.to!string);
121 		string[] imports;
122 		foreach (i; additionalImports)
123 			if (i.length)
124 				imports ~= "-I" ~ i;
125 		this.runningPort = port;
126 		this.socketFile = buildPath(tempDir,
127 				"workspace-d-sock" ~ thisProcessID.to!string ~ "-" ~ uniform!ulong.to!string(36));
128 		serverPipes = raw([serverPath] ~ clientArgs ~ imports,
129 				Redirect.stdin | Redirect.stderr | Redirect.stdoutToStderr);
130 		while (!serverPipes.stderr.eof)
131 		{
132 			string line = serverPipes.stderr.readln();
133 			if (!quietServer)
134 				trace("Server: ", line);
135 			if (line.canFind("Startup completed in "))
136 				break;
137 		}
138 		running = true;
139 		serverThreads.create({
140 			mixin(traceTask);
141 			if (quietServer)
142 				foreach (block; serverPipes.stderr.byChunk(4096))
143 				{
144 				}
145 			else
146 				while (serverPipes.stderr.isOpen && !serverPipes.stderr.eof)
147 				{
148 					auto line = serverPipes.stderr.readln();
149 					trace("Server: ", line); // evaluates lazily, so read before
150 				}
151 			auto code = serverPipes.pid.wait();
152 			info("DCD-Server stopped with code ", code);
153 			if (code != 0)
154 			{
155 				info("Broadcasting dcd server crash.");
156 				workspaced.broadcast(refInstance, JSONValue([
157 						"type": JSONValue("crash"),
158 						"component": JSONValue("dcd")
159 					]));
160 				running = false;
161 			}
162 		});
163 	}
164 
165 	void stopServerSync()
166 	{
167 		if (!running)
168 			return;
169 		int i = 0;
170 		running = false;
171 		doClient(["--shutdown"]).pid.wait;
172 		while (serverPipes.pid && !serverPipes.pid.tryWait().terminated)
173 		{
174 			Thread.sleep(10.msecs);
175 			if (++i > 200) // Kill after 2 seconds
176 			{
177 				killServer();
178 				return;
179 			}
180 		}
181 	}
182 
183 	/// This stops the dcd-server asynchronously
184 	/// Returns: null
185 	Future!void stopServer()
186 	{
187 		auto ret = new Future!void();
188 		gthreads.create({
189 			mixin(traceTask);
190 			try
191 			{
192 				stopServerSync();
193 				ret.finish();
194 			}
195 			catch (Throwable t)
196 			{
197 				ret.error(t);
198 			}
199 		});
200 		return ret;
201 	}
202 
203 	/// This will kill the process associated with the dcd-server instance
204 	void killServer()
205 	{
206 		if (serverPipes.pid && !serverPipes.pid.tryWait().terminated)
207 			serverPipes.pid.kill();
208 	}
209 
210 	/// This will stop the dcd-server safely and restart it again using setup-server asynchronously
211 	/// Returns: null
212 	Future!void restartServer(bool quiet = false)
213 	{
214 		auto ret = new Future!void;
215 		gthreads.create({
216 			mixin(traceTask);
217 			try
218 			{
219 				stopServerSync();
220 				setupServer([], quiet);
221 				ret.finish();
222 			}
223 			catch (Throwable t)
224 			{
225 				ret.error(t);
226 			}
227 		});
228 		return ret;
229 	}
230 
231 	/// This will query the current dcd-server status
232 	/// Returns: `{isRunning: bool}` If the dcd-server process is not running anymore it will return isRunning: false. Otherwise it will check for server status using `dcd-client --query`
233 	auto serverStatus() @property
234 	{
235 		DCDServerStatus status;
236 		if (serverPipes.pid && serverPipes.pid.tryWait().terminated)
237 			status.isRunning = false;
238 		else if (hasUnixDomainSockets)
239 			status.isRunning = true;
240 		else
241 			status.isRunning = isPortRunning(runningPort);
242 		return status;
243 	}
244 
245 	/// Searches for a symbol across all files using `dcd-client --search`
246 	Future!(DCDSearchResult[]) searchSymbol(string query)
247 	{
248 		auto ret = new Future!(DCDSearchResult[]);
249 		gthreads.create({
250 			mixin(traceTask);
251 			try
252 			{
253 				if (!running)
254 				{
255 					ret.finish(null);
256 					return;
257 				}
258 				auto pipes = doClient(["--search", query]);
259 				scope (exit)
260 				{
261 					pipes.pid.wait();
262 					pipes.destroy();
263 				}
264 				pipes.stdin.close();
265 				DCDSearchResult[] results;
266 				while (pipes.stdout.isOpen && !pipes.stdout.eof)
267 				{
268 					string line = pipes.stdout.readln();
269 					if (line.length == 0)
270 						continue;
271 					string[] splits = line.chomp.split('\t');
272 					if (splits.length >= 3)
273 						results ~= DCDSearchResult(splits[0], splits[2].to!int, splits[1]);
274 				}
275 				ret.finish(results);
276 			}
277 			catch (Throwable t)
278 			{
279 				ret.error(t);
280 			}
281 		});
282 		return ret;
283 	}
284 
285 	/// Reloads import paths from the current provider. Call reload there before calling it here.
286 	void refreshImports()
287 	{
288 		addImports(importPaths ~ importFiles);
289 	}
290 
291 	/// Manually adds import paths as string array
292 	void addImports(string[] imports)
293 	{
294 		knownImports ~= imports;
295 		updateImports();
296 	}
297 
298 	string clientPath() @property @ignoredFunc
299 	{
300 		return config.get("dcd", "clientPath", "dcd-client");
301 	}
302 
303 	string serverPath() @property @ignoredFunc
304 	{
305 		return config.get("dcd", "serverPath", "dcd-server");
306 	}
307 
308 	ushort port() @property @ignoredFunc
309 	{
310 		return cast(ushort) config.get!int("dcd", "port", 9166);
311 	}
312 
313 	/// Searches for an open port to spawn dcd-server in asynchronously starting with `port`, always increasing by one.
314 	/// Returns: 0 if not available, otherwise the port as number
315 	Future!ushort findAndSelectPort(ushort port = 9166)
316 	{
317 		if (hasUnixDomainSockets)
318 		{
319 			return Future!ushort.fromResult(0);
320 		}
321 		auto ret = new Future!ushort;
322 		gthreads.create({
323 			mixin(traceTask);
324 			try
325 			{
326 				auto newPort = findOpen(port);
327 				port = newPort;
328 				ret.finish(port);
329 			}
330 			catch (Throwable t)
331 			{
332 				ret.error(t);
333 			}
334 		});
335 		return ret;
336 	}
337 
338 	/// Finds the declaration of the symbol at position `pos` in the code
339 	Future!DCDDeclaration findDeclaration(scope const(char)[] code, int pos)
340 	{
341 		auto ret = new Future!DCDDeclaration;
342 		gthreads.create({
343 			mixin(traceTask);
344 			try
345 			{
346 				if (!running || pos >= code.length)
347 				{
348 					ret.finish(DCDDeclaration.init);
349 					return;
350 				}
351 
352 				// We need to move by one character on identifier characters to ensure the start character fits.
353 				if (!isIdentifierSeparatingChar(code[pos]))
354 					pos++;
355 
356 				auto pipes = doClient(["-c", pos.to!string, "--symbolLocation"]);
357 				scope (exit)
358 				{
359 					pipes.pid.wait();
360 					pipes.destroy();
361 				}
362 				pipes.stdin.write(code);
363 				pipes.stdin.close();
364 				string line = pipes.stdout.readln();
365 				if (line.length == 0)
366 				{
367 					ret.finish(DCDDeclaration.init);
368 					return;
369 				}
370 				string[] splits = line.chomp.split('\t');
371 				if (splits.length != 2)
372 				{
373 					ret.finish(DCDDeclaration.init);
374 					return;
375 				}
376 				ret.finish(DCDDeclaration(splits[0], splits[1].to!int));
377 			}
378 			catch (Throwable t)
379 			{
380 				ret.error(t);
381 			}
382 		});
383 		return ret;
384 	}
385 
386 	/// Finds the documentation of the symbol at position `pos` in the code
387 	Future!string getDocumentation(scope const(char)[] code, int pos)
388 	{
389 		auto ret = new Future!string;
390 		gthreads.create({
391 			mixin(traceTask);
392 			try
393 			{
394 				if (!running)
395 				{
396 					ret.finish("");
397 					return;
398 				}
399 				auto pipes = doClient(["--doc", "-c", pos.to!string]);
400 				scope (exit)
401 				{
402 					pipes.pid.wait();
403 					pipes.destroy();
404 				}
405 				pipes.stdin.write(code);
406 				pipes.stdin.close();
407 				string data;
408 				while (pipes.stdout.isOpen && !pipes.stdout.eof)
409 				{
410 					string line = pipes.stdout.readln();
411 					if (line.length)
412 						data ~= line.chomp;
413 				}
414 				ret.finish(data.unescapeTabs);
415 			}
416 			catch (Throwable t)
417 			{
418 				ret.error(t);
419 			}
420 		});
421 		return ret;
422 	}
423 
424 	/// Returns the used socket file. Only available on OSX, linux and BSD with DCD >= 0.8.0
425 	/// Throws an error if not available.
426 	string getSocketFile()
427 	{
428 		if (!hasUnixDomainSockets)
429 			throw new Exception("Unix domain sockets not supported");
430 		return socketFile;
431 	}
432 
433 	/// Returns the used running port. Throws an error if using unix sockets instead
434 	ushort getRunningPort()
435 	{
436 		if (hasUnixDomainSockets)
437 			throw new Exception("Using unix domain sockets instead of a port");
438 		return runningPort;
439 	}
440 
441 	/// Queries for code completion at position `pos` in code
442 	/// Raw is anything else than identifiers and calltips which might not be implemented by this point.
443 	/// calltips.symbols and identifiers.definition, identifiers.file, identifiers.location and identifiers.documentation are only available with dcd ~master as of now.
444 	Future!DCDCompletions listCompletion(scope const(char)[] code, int pos)
445 	{
446 		auto ret = new Future!DCDCompletions;
447 		gthreads.create({
448 			mixin(traceTask);
449 			try
450 			{
451 				DCDCompletions completions;
452 				if (!running)
453 				{
454 					info("DCD not running!");
455 					ret.finish(completions);
456 					return;
457 				}
458 				auto pipes = doClient((supportsFullOutput ? ["--extended"] : []) ~ [
459 						"-c", pos.to!string
460 					]);
461 				scope (exit)
462 				{
463 					pipes.pid.wait();
464 					pipes.destroy();
465 				}
466 				pipes.stdin.write(code);
467 				pipes.stdin.close();
468 				string[] data;
469 				while (pipes.stdout.isOpen && !pipes.stdout.eof)
470 				{
471 					string line = pipes.stdout.readln();
472 					trace("DCD Client: ", line);
473 					if (line.length == 0)
474 						continue;
475 					data ~= line.chomp;
476 				}
477 				completions.raw = data;
478 				int[] emptyArr;
479 				if (data.length == 0)
480 				{
481 					completions.type = DCDCompletions.Type.identifiers;
482 					ret.finish(completions);
483 					return;
484 				}
485 				if (data[0] == "calltips")
486 				{
487 					if (supportsFullOutput)
488 					{
489 						foreach (line; data[1 .. $])
490 						{
491 							auto parts = line.split("\t");
492 							if (parts.length < 5)
493 								continue;
494 							completions._calltips ~= parts[2];
495 							string location = parts[3];
496 							string file;
497 							int index;
498 							if (location.length)
499 							{
500 								auto space = location.lastIndexOf(' ');
501 								if (space != -1)
502 								{
503 									file = location[0 .. space];
504 									if (location[space + 1 .. $].all!isDigit)
505 										index = location[space + 1 .. $].to!int;
506 								}
507 								else
508 									file = location;
509 							}
510 							completions._symbols ~= DCDCompletions.Symbol(file, index, parts[4].unescapeTabs);
511 						}
512 					}
513 					else
514 					{
515 						completions._calltips = data[1 .. $];
516 						completions._symbols.length = completions._calltips.length;
517 					}
518 					completions.type = DCDCompletions.Type.calltips;
519 					ret.finish(completions);
520 					return;
521 				}
522 				else if (data[0] == "identifiers")
523 				{
524 					DCDIdentifier[] identifiers;
525 					foreach (line; data[1 .. $])
526 					{
527 						string[] splits = line.split('\t');
528 						DCDIdentifier symbol;
529 						if (supportsFullOutput)
530 						{
531 							if (splits.length < 5)
532 								continue;
533 							string location = splits[3];
534 							string file;
535 							int index;
536 							if (location.length)
537 							{
538 								auto space = location.lastIndexOf(' ');
539 								if (space != -1)
540 								{
541 									file = location[0 .. space];
542 									if (location[space + 1 .. $].all!isDigit)
543 										index = location[space + 1 .. $].to!int;
544 								}
545 								else
546 									file = location;
547 							}
548 							symbol = DCDIdentifier(splits[0], splits[1], splits[2], file,
549 								index, splits[4].unescapeTabs);
550 						}
551 						else
552 						{
553 							if (splits.length < 2)
554 								continue;
555 							symbol = DCDIdentifier(splits[0], splits[1]);
556 						}
557 						identifiers ~= symbol;
558 					}
559 					completions.type = DCDCompletions.Type.identifiers;
560 					completions._identifiers = identifiers;
561 					ret.finish(completions);
562 					return;
563 				}
564 				else
565 				{
566 					completions.type = DCDCompletions.Type.raw;
567 					ret.finish(completions);
568 					return;
569 				}
570 			}
571 			catch (Throwable e)
572 			{
573 				ret.error(e);
574 			}
575 		});
576 		return ret;
577 	}
578 
579 	void updateImports()
580 	{
581 		if (!running)
582 			return;
583 		string[] args;
584 		foreach (path; knownImports)
585 			if (path.length)
586 				args ~= "-I" ~ path;
587 		execClient(args);
588 	}
589 
590 	bool fromRunning(bool supportsFullOutput, string socketFile, ushort runningPort)
591 	{
592 		if (socketFile.length ? isSocketRunning(socketFile) : isPortRunning(runningPort))
593 		{
594 			running = true;
595 			this.supportsFullOutput = supportsFullOutput;
596 			this.socketFile = socketFile;
597 			this.runningPort = runningPort;
598 			this.hasUnixDomainSockets = !!socketFile.length;
599 			return true;
600 		}
601 		else
602 			return false;
603 	}
604 
605 	bool getSupportsFullOutput() @property
606 	{
607 		return supportsFullOutput;
608 	}
609 
610 	bool isUsingUnixDomainSockets() @property
611 	{
612 		return hasUnixDomainSockets;
613 	}
614 
615 	bool isActive() @property
616 	{
617 		return running;
618 	}
619 
620 private:
621 	string installedVersion;
622 	bool supportsFullOutput;
623 	bool hasUnixDomainSockets = false;
624 	bool running = false;
625 	ProcessPipes serverPipes;
626 	ushort runningPort;
627 	string socketFile;
628 	string[] knownImports;
629 
630 	string[] clientArgs()
631 	{
632 		if (hasUnixDomainSockets)
633 			return ["--socketFile", socketFile];
634 		else
635 			return ["--port", runningPort.to!string];
636 	}
637 
638 	auto doClient(string[] args)
639 	{
640 		return raw([clientPath] ~ clientArgs ~ args);
641 	}
642 
643 	auto raw(string[] args, Redirect redirect = Redirect.all)
644 	{
645 		return pipeProcess(args, redirect, null, Config.none, refInstance ? instance.cwd : null);
646 	}
647 
648 	auto execClient(string[] args)
649 	{
650 		return rawExec([clientPath] ~ clientArgs ~ args);
651 	}
652 
653 	auto rawExec(string[] args)
654 	{
655 		return execute(args, null, Config.none, size_t.max, refInstance ? instance.cwd : null);
656 	}
657 
658 	bool isSocketRunning(string socket)
659 	{
660 		if (!hasUnixDomainSockets)
661 			return false;
662 		auto ret = execute([clientPath, "-q", "--socketFile", socket]);
663 		return ret.status == 0;
664 	}
665 
666 	bool isPortRunning(ushort port)
667 	{
668 		if (hasUnixDomainSockets)
669 			return false;
670 		auto ret = execute([clientPath, "-q", "--port", port.to!string]);
671 		return ret.status == 0;
672 	}
673 
674 	ushort findOpen(ushort port)
675 	{
676 		--port;
677 		bool isRunning;
678 		do
679 		{
680 			isRunning = isPortRunning(++port);
681 		}
682 		while (isRunning);
683 		return port;
684 	}
685 }
686 
687 bool supportsUnixDomainSockets(string ver)
688 {
689 	return checkVersion(ver, [0, 8, 0]);
690 }
691 
692 unittest
693 {
694 	assert(supportsUnixDomainSockets("0.8.0-beta2+9ec55f40a26f6bb3ca95dc9232a239df6ed25c37"));
695 	assert(!supportsUnixDomainSockets("0.7.9-beta3"));
696 	assert(!supportsUnixDomainSockets("0.7.0"));
697 	assert(supportsUnixDomainSockets("v0.9.8 c7ea7e081ed9ad2d85e9f981fd047d7fcdb2cf51"));
698 	assert(supportsUnixDomainSockets("1.0.0"));
699 }
700 
701 private string unescapeTabs(string val)
702 {
703 	return val.replace("\\t", "\t").replace("\\n", "\n").replace("\\\\", "\\");
704 }
705 
706 /// Returned by findDeclaration
707 struct DCDDeclaration
708 {
709 	string file;
710 	int position;
711 }
712 
713 /// Returned by listCompletion
714 /// When identifiers: `{type:"identifiers", identifiers:[{identifier:string, type:string, definition:string, file:string, location:number, documentation:string}]}`
715 /// When calltips: `{type:"calltips", calltips:[string], symbols:[{file:string, location:number, documentation:string}]}`
716 /// When raw: `{type:"raw", raw:[string]}`
717 struct DCDCompletions
718 {
719 	/// Type of a completion
720 	enum Type
721 	{
722 		/// Unknown/Unimplemented raw output
723 		raw,
724 		/// Completion after a dot or a variable name
725 		identifiers,
726 		/// Completion for arguments in a function call
727 		calltips,
728 	}
729 
730 	struct Symbol
731 	{
732 		string file;
733 		int location;
734 		string documentation;
735 	}
736 
737 	/// Type of the completion (identifiers, calltips, raw)
738 	Type type;
739 	/// Contains the raw DCD output
740 	string[] raw;
741 	union
742 	{
743 		DCDIdentifier[] _identifiers;
744 		struct
745 		{
746 			string[] _calltips;
747 			Symbol[] _symbols;
748 		}
749 	}
750 
751 	enum DCDCompletions empty = DCDCompletions(Type.identifiers);
752 
753 	/// Only set with type==identifiers.
754 	inout(DCDIdentifier[]) identifiers() inout @property
755 	{
756 		if (type != Type.identifiers)
757 			throw new Exception("Type is not identifiers but attempted to access identifiers");
758 		return _identifiers;
759 	}
760 
761 	/// Only set with type==calltips.
762 	inout(string[]) calltips() inout @property
763 	{
764 		if (type != Type.calltips)
765 			throw new Exception("Type is not calltips but attempted to access calltips");
766 		return _calltips;
767 	}
768 
769 	/// Only set with type==calltips.
770 	inout(Symbol[]) symbols() inout @property
771 	{
772 		if (type != Type.calltips)
773 			throw new Exception("Type is not calltips but attempted to access symbols");
774 		return _symbols;
775 	}
776 }
777 
778 /// Returned by status
779 struct DCDServerStatus
780 {
781 	///
782 	bool isRunning;
783 }
784 
785 /// Type of the identifiers value in listCompletion
786 struct DCDIdentifier
787 {
788 	///
789 	string identifier;
790 	///
791 	string type;
792 	///
793 	string definition;
794 	///
795 	string file;
796 	/// byte location
797 	int location;
798 	///
799 	string documentation;
800 }
801 
802 /// Returned by search-symbol
803 struct DCDSearchResult
804 {
805 	///
806 	string file;
807 	///
808 	int position;
809 	///
810 	string type;
811 }