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