1 module workspaced.com.snippets;
2 
3 import dparse.lexer;
4 import dparse.parser;
5 import dparse.rollback_allocator;
6 
7 import workspaced.api;
8 import workspaced.com.dfmt : DfmtComponent;
9 import workspaced.com.snippets.generator;
10 
11 import std.algorithm;
12 import std.array;
13 import std.ascii;
14 import std.conv;
15 import std.json;
16 import std.string;
17 import std.typecons;
18 
19 public import workspaced.com.snippets.plain;
20 public import workspaced.com.snippets.smart;
21 public import workspaced.com.snippets.dependencies;
22 
23 /// Component for auto completing snippets with context information and formatting these snippets with dfmt.
24 @component("snippets")
25 class SnippetsComponent : ComponentWrapper
26 {
27 	mixin DefaultComponentWrapper;
28 
29 	static PlainSnippetProvider plainSnippets;
30 	static SmartSnippetProvider smartSnippets;
31 	static DependencyBasedSnippetProvider dependencySnippets;
32 
33 	protected SnippetProvider[] providers;
34 
35 	protected void load()
36 	{
37 		if (!plainSnippets)
38 			plainSnippets = new PlainSnippetProvider();
39 		if (!smartSnippets)
40 			smartSnippets = new SmartSnippetProvider();
41 		if (!dependencySnippets)
42 			dependencySnippets = new DependencyBasedSnippetProvider();
43 
44 		config.stringBehavior = StringBehavior.source;
45 		providers.reserve(16);
46 		providers ~= plainSnippets;
47 		providers ~= smartSnippets;
48 		providers ~= dependencySnippets;
49 	}
50 
51 	/**
52 	 * Params:
53 	 *   file = Filename to resolve dependencies relatively from.
54 	 *   code = Code to complete snippet in.
55 	 *   position = Byte offset of where to find scope in.
56 	 *
57 	 * Returns: a `SnippetInfo` object for all snippet information.
58 	 *
59 	 * `.loopScope` is set if a loop can be inserted at this position, Optionally
60 	 * with information about close ranges. Contains `SnippetLoopScope.init` if
61 	 * this is not a location where a loop can be inserted.
62 	 */
63 	SnippetInfo determineSnippetInfo(scope const(char)[] file, scope const(char)[] code, int position)
64 	{
65 		// each variable is 1
66 		// maybe more expensive lookups with DCD in the future
67 		enum LoopVariableAnalyzeMaxCost = 90;
68 
69 		scope tokens = getTokensForParser(cast(ubyte[]) code, config, &workspaced.stringCache);
70 		// TODO: binary search
71 		size_t loc;
72 		foreach (i, tok; tokens)
73 			if (tok.index >= position)
74 			{
75 				loc = i;
76 				break;
77 			}
78 
79 		auto leading = tokens[0 .. loc];
80 		foreach_reverse (t; leading)
81 		{
82 			if (t.type == tok!";")
83 				break;
84 
85 			// test for tokens semicolon closed statements where we should abort to avoid incomplete syntax
86 			if (t.type.among!(tok!"import", tok!"module"))
87 			{
88 				return SnippetInfo([SnippetLevel.global, SnippetLevel.other]);
89 			}
90 			else if (t.type.among!(tok!"=", tok!"+", tok!"-", tok!"*", tok!"/",
91 					tok!"%", tok!"^^", tok!"&", tok!"|", tok!"^", tok!"<<",
92 					tok!">>", tok!">>>", tok!"~", tok!"in"))
93 			{
94 				return SnippetInfo([SnippetLevel.global, SnippetLevel.value]);
95 			}
96 		}
97 
98 		RollbackAllocator rba;
99 		scope parsed = parseModule(tokens, cast(string) file, &rba);
100 
101 		trace("determineSnippetInfo at ", position);
102 
103 		scope gen = new SnippetInfoGenerator(position);
104 		gen.variableStack.reserve(64);
105 		gen.visit(parsed);
106 
107 		gen.value.loopScope.supported = gen.value.level == SnippetLevel.method;
108 		if (gen.value.loopScope.supported)
109 		{
110 			int cost = 0;
111 			foreach_reverse (v; gen.variableStack)
112 			{
113 				if (fillLoopScopeInfo(gen.value.loopScope, v))
114 					break;
115 				if (++cost > LoopVariableAnalyzeMaxCost)
116 					break;
117 			}
118 		}
119 
120 		return gen.value;
121 	}
122 
123 	Future!SnippetList getSnippets(scope const(char)[] file, scope const(char)[] code, int position)
124 	{
125 		mixin(gthreadsAsyncProxy!`getSnippetsBlocking(file, code, position)`);
126 	}
127 
128 	SnippetList getSnippetsBlocking(scope const(char)[] file, scope const(char)[] code, int position)
129 	{
130 		auto futures = collectSnippets(file, code, position);
131 
132 		auto ret = appender!(Snippet[]);
133 		foreach (fut; futures[1])
134 			ret.put(fut.getBlocking());
135 		return SnippetList(futures[0], ret.data);
136 	}
137 
138 	SnippetList getSnippetsYield(scope const(char)[] file, scope const(char)[] code, int position)
139 	{
140 		auto futures = collectSnippets(file, code, position);
141 
142 		auto ret = appender!(Snippet[]);
143 		foreach (fut; futures[1])
144 			ret.put(fut.getYield());
145 		return SnippetList(futures[0], ret.data);
146 	}
147 
148 	Future!Snippet resolveSnippet(scope const(char)[] file, scope const(char)[] code,
149 			int position, Snippet snippet)
150 	{
151 		foreach (provider; providers)
152 		{
153 			if (typeid(provider).name == snippet.providerId)
154 			{
155 				const info = determineSnippetInfo(file, code, position);
156 				return provider.resolveSnippet(instance, file, code, position, info, snippet);
157 			}
158 		}
159 
160 		return Future!Snippet.fromResult(snippet);
161 	}
162 
163 	Future!string format(scope const(char)[] snippet, string[] arguments = [],
164 			SnippetLevel level = SnippetLevel.global)
165 	{
166 		mixin(gthreadsAsyncProxy!`formatSync(snippet, arguments, level)`);
167 	}
168 
169 	/// Will format the code passed in synchronously using dfmt. Might take a short moment on larger documents.
170 	/// Returns: the formatted code as string or unchanged if dfmt is not active
171 	string formatSync(scope const(char)[] snippet, string[] arguments = [],
172 			SnippetLevel level = SnippetLevel.global)
173 	{
174 		if (!has!DfmtComponent)
175 			return snippet.idup;
176 
177 		auto dfmt = get!DfmtComponent;
178 
179 		auto tmp = appender!string;
180 
181 		final switch (level)
182 		{
183 		case SnippetLevel.global:
184 		case SnippetLevel.other:
185 			break;
186 		case SnippetLevel.type:
187 			tmp.put("struct FORMAT_HELPER {\n");
188 			break;
189 		case SnippetLevel.method:
190 			tmp.put("void FORMAT_HELPER() {\n");
191 			break;
192 		case SnippetLevel.value:
193 			tmp.put("int FORMAT_HELPER() = ");
194 			break;
195 		}
196 
197 		scope const(char)[][string] tokens;
198 
199 		ptrdiff_t dollar, last;
200 		while (true)
201 		{
202 			dollar = snippet.indexOfAny(`$\`, last);
203 			if (dollar == -1)
204 			{
205 				tmp ~= snippet[last .. $];
206 				break;
207 			}
208 
209 			tmp ~= snippet[last .. dollar];
210 			last = dollar + 1;
211 			if (last >= snippet.length)
212 				break;
213 			if (snippet[dollar] == '\\')
214 			{
215 				tmp ~= snippet[dollar + 1];
216 				last = dollar + 2;
217 			}
218 			else
219 			{
220 				string key = "__WspD_Snp_" ~ dollar.to!string ~ "_";
221 				const(char)[] str;
222 
223 				bool startOfBlock = snippet[0 .. dollar].stripRight.endsWith("{");
224 				bool endOfBlock;
225 
226 				bool makeWrappingIfMayBeDelegate()
227 				{
228 					endOfBlock = snippet[last .. $].stripLeft.startsWith("}");
229 					if (startOfBlock && endOfBlock)
230 					{
231 						// make extra long to make dfmt definitely wrap this (in case this is a delegate, otherwise this doesn't hurt either)
232 						key.reserve(key.length + 200);
233 						foreach (i; 0 .. 200)
234 							key ~= "_";
235 						return true;
236 					}
237 					else
238 						return false;
239 				}
240 
241 				if (snippet[dollar + 1] == '{')
242 				{
243 					ptrdiff_t i = dollar + 2;
244 					int depth = 1;
245 					while (true)
246 					{
247 						auto next = snippet.indexOfAny(`\{}`, i);
248 						if (next == -1)
249 						{
250 							i = snippet.length;
251 							break;
252 						}
253 
254 						if (snippet[next] == '\\')
255 							i = next + 2;
256 						else
257 						{
258 							if (snippet[next] == '{')
259 								depth++;
260 							else if (snippet[next] == '}')
261 								depth--;
262 							else
263 								assert(false);
264 
265 							i = next + 1;
266 						}
267 
268 						if (depth == 0)
269 							break;
270 					}
271 					str = snippet[dollar .. i];
272 					last = i;
273 
274 					const wrapped = makeWrappingIfMayBeDelegate();
275 
276 					const placeholderMightBeIdentifier = str.length > 5
277 						|| snippet[last .. $].stripLeft.startsWith(";", ".", "{");
278 
279 					if (wrapped || placeholderMightBeIdentifier)
280 					{
281 						// let's insert some token in here instead of a comment because there is probably some default content
282 						// if there is a semicolon at the end we probably need to insert a semicolon here too
283 						// if this is a comment placeholder let's insert a semicolon to make dfmt wrap
284 						if (str[0 .. $ - 1].endsWith(';') || str[0 .. $ - 1].canFind("//"))
285 							key ~= ';';
286 					}
287 					else if (level != SnippetLevel.value)
288 					{
289 						// empty default, put in comment
290 						key = "/+++" ~ key ~ "+++/";
291 					}
292 				}
293 				else
294 				{
295 					size_t end = dollar + 1;
296 
297 					if (snippet[dollar + 1].isDigit)
298 					{
299 						while (end < snippet.length && snippet[end].isDigit)
300 							end++;
301 					}
302 					else
303 					{
304 						while (end < snippet.length && (snippet[end].isAlphaNum || snippet[end] == '_'))
305 							end++;
306 					}
307 
308 					str = snippet[dollar .. end];
309 					last = end;
310 
311 					makeWrappingIfMayBeDelegate();
312 
313 					const placeholderMightBeIdentifier = snippet[last .. $].stripLeft.startsWith(";",
314 							".", "{");
315 
316 					if (placeholderMightBeIdentifier)
317 					{
318 						// keep value thing as simple identifier as we don't have any placeholder text
319 					}
320 					else if (level != SnippetLevel.value)
321 					{
322 						// primitive placeholder as comment
323 						key = "/+++" ~ key ~ "+++/";
324 					}
325 				}
326 
327 				tokens[key] = str;
328 				tmp ~= key;
329 			}
330 		}
331 
332 		final switch (level)
333 		{
334 		case SnippetLevel.global:
335 		case SnippetLevel.other:
336 			break;
337 		case SnippetLevel.type:
338 		case SnippetLevel.method:
339 			tmp.put("}");
340 			break;
341 		case SnippetLevel.value:
342 			tmp.put(";");
343 			break;
344 		}
345 
346 		auto res = dfmt.formatSync(tmp.data, arguments);
347 
348 		string chompStr;
349 		char del;
350 		final switch (level)
351 		{
352 		case SnippetLevel.global:
353 		case SnippetLevel.other:
354 			break;
355 		case SnippetLevel.type:
356 		case SnippetLevel.method:
357 			chompStr = "}";
358 			del = '{';
359 			break;
360 		case SnippetLevel.value:
361 			chompStr = ";";
362 			del = '=';
363 			break;
364 		}
365 
366 		if (chompStr.length)
367 			res = res.stripRight.chomp(chompStr);
368 
369 		if (del != char.init)
370 		{
371 			auto start = res.indexOf(del);
372 			if (start != -1)
373 			{
374 				res = res[start + 1 .. $];
375 
376 				while (true)
377 				{
378 					// delete empty lines before first line
379 					auto nl = res.indexOf('\n');
380 					if (nl != -1 && res[0 .. nl].all!isWhite)
381 						res = res[nl + 1 .. $];
382 					else
383 						break;
384 				}
385 
386 				auto indent = res[0 .. res.length - res.stripLeft.length];
387 				if (indent.length)
388 				{
389 					// remove indentation of whole block
390 					assert(indent.all!isWhite);
391 					res = res.splitLines.map!(a => a.startsWith(indent)
392 							? a[indent.length .. $] : a.stripRight).join("\n");
393 				}
394 			}
395 		}
396 
397 		foreach (key, value; tokens)
398 		{
399 			// TODO: replacing using aho-corasick would be far more efficient but there is nothing like that in phobos
400 			res = res.replace(key, value);
401 		}
402 
403 		if (res.endsWith("\r\n") && !snippet.endsWith('\n'))
404 			res.length -= 2;
405 		else if (res.endsWith('\n') && !snippet.endsWith('\n'))
406 			res.length--;
407 
408 		if (res.endsWith(";\n\n$0"))
409 			res = res[0 .. $ - "\n$0".length] ~ "$0";
410 		else if (res.endsWith(";\r\n\r\n$0"))
411 			res = res[0 .. $ - "\r\n$0".length] ~ "$0";
412 
413 		return res;
414 	}
415 
416 	/// Adds snippets which complete conditionally based on dub dependencies being present.
417 	/// This function affects the global configuration of all instances.
418 	/// Params:
419 	///   requiredDependencies = The dependencies which must be present in order for this snippet to show up.
420 	///   snippet = The snippet to suggest when the required dependencies are matched.
421 	void addDependencySnippet(string[] requiredDependencies, PlainSnippet snippet)
422 	{
423 		// maybe application global change isn't such a good idea? Current config system seems too inefficient for this.
424 		dependencySnippets.addSnippet(requiredDependencies, snippet);
425 	}
426 
427 private:
428 	Tuple!(SnippetInfo, Future!(Snippet[])[]) collectSnippets(scope const(char)[] file,
429 			scope const(char)[] code, int position)
430 	{
431 		const inst = instance;
432 		auto info = determineSnippetInfo(file, code, position);
433 		auto futures = appender!(Future!(Snippet[])[]);
434 		foreach (provider; providers)
435 			futures.put(provider.provideSnippets(inst, file, code, position, info));
436 		return tuple(info, futures.data);
437 	}
438 
439 	LexerConfig config;
440 }
441 
442 ///
443 enum SnippetLevel
444 {
445 	/// Outside of functions or types, possibly inside templates
446 	global,
447 	/// Inside interfaces, classes, structs or unions
448 	type,
449 	/// Inside method body
450 	method,
451 	/// inside a variable value, argument call, default value or similar
452 	value,
453 	/// Other scope types (for example outside of braces but after a function definition or some other invalid syntax place)
454 	other
455 }
456 
457 ///
458 struct SnippetLoopScope
459 {
460 	/// true if an loop expression can be inserted at this point
461 	bool supported;
462 	/// true if we know we are iterating over a string (possibly needing unicode decoding) or false otherwise
463 	bool stringIterator;
464 	/// Explicit type to use when iterating or null if none is known
465 	string type;
466 	/// Best variable to iterate over or null if none was found
467 	string iterator;
468 	/// Number of keys to iterate over
469 	int numItems = 1;
470 }
471 
472 ///
473 struct SnippetInfo
474 {
475 	/// Levels this snippet location has gone through, latest one being the last
476 	SnippetLevel[] stack = [SnippetLevel.global];
477 	/// Information about snippets using loop context
478 	SnippetLoopScope loopScope;
479 
480 	/// Current snippet scope level of the location
481 	SnippetLevel level() const @property
482 	{
483 		return stack.length ? stack[$ - 1] : SnippetLevel.other;
484 	}
485 }
486 
487 /// A list of snippets resolved at a given position.
488 struct SnippetList
489 {
490 	/// The info where this snippet is completing at.
491 	SnippetInfo info;
492 	/// The list of snippets that got returned.
493 	Snippet[] snippets;
494 }
495 
496 ///
497 interface SnippetProvider
498 {
499 	Future!(Snippet[]) provideSnippets(scope const WorkspaceD.Instance instance,
500 			scope const(char)[] file, scope const(char)[] code, int position, const SnippetInfo info);
501 
502 	Future!Snippet resolveSnippet(scope const WorkspaceD.Instance instance,
503 			scope const(char)[] file, scope const(char)[] code, int position,
504 			const SnippetInfo info, Snippet snippet);
505 }
506 
507 /// Snippet to insert
508 struct Snippet
509 {
510 	/// Internal ID for resolving this snippet
511 	string id, providerId;
512 	/// User-defined data for helping resolving this snippet
513 	JSONValue data;
514 	/// Label for this snippet
515 	string title;
516 	/// Shortcut to type for this snippet
517 	string shortcut;
518 	/// Markdown documentation for this snippet
519 	string documentation;
520 	/// Plain text to insert assuming global level indentation.
521 	string plain;
522 	/// Text with interactive snippet locations to insert assuming global indentation.
523 	string snippet;
524 	/// true if this snippet can be used as-is
525 	bool resolved;
526 }
527 
528 unittest
529 {
530 	scope backend = new WorkspaceD();
531 	auto workspace = makeTemporaryTestingWorkspace;
532 	auto instance = backend.addInstance(workspace.directory);
533 	backend.register!SnippetsComponent;
534 	backend.register!DfmtComponent;
535 	SnippetsComponent snippets = backend.get!SnippetsComponent(workspace.directory);
536 
537 	auto args = ["--indent_style", "tab"];
538 
539 	auto res = snippets.formatSync("void main(${1:string[] args}) {\n\t$0\n}", args);
540 	shouldEqual(res, "void main(${1:string[] args})\n{\n\t$0\n}");
541 
542 	res = snippets.formatSync("class ${1:MyClass} {\n\t$0\n}", args);
543 	shouldEqual(res, "class ${1:MyClass}\n{\n\t$0\n}");
544 
545 	res = snippets.formatSync("enum ${1:MyEnum} = $2;\n$0", args);
546 	shouldEqual(res, "enum ${1:MyEnum} = $2;\n$0");
547 
548 	res = snippets.formatSync("import ${1:std};\n$0", args);
549 	shouldEqual(res, "import ${1:std};\n$0");
550 
551 	res = snippets.formatSync("import ${1:std};\n$0", args, SnippetLevel.method);
552 	shouldEqual(res, "import ${1:std};\n$0");
553 
554 	res = snippets.formatSync("foo(delegate() {\n${1:// foo}\n});", args, SnippetLevel.method);
555 	shouldEqual(res, "foo(delegate() {\n\t${1:// foo}\n});");
556 
557 	res = snippets.formatSync(`auto ${1:window} = new SimpleWindow(Size(${2:800, 600}), "$3");`, args, SnippetLevel.method);
558 	shouldEqual(res, `auto ${1:window} = new SimpleWindow(Size(${2:800, 600}), "$3");`);
559 }