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