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 
18 public import workspaced.com.snippets.plain;
19 public import workspaced.com.snippets.smart;
20 public import workspaced.com.snippets.dependencies;
21 
22 /// Component for auto completing snippets with context information and formatting these snippets with dfmt.
23 @component("snippets")
24 class SnippetsComponent : ComponentWrapper
25 {
26 	mixin DefaultComponentWrapper;
27 
28 	static PlainSnippetProvider plainSnippets;
29 	static SmartSnippetProvider smartSnippets;
30 	static DependencyBasedSnippetProvider dependencySnippets;
31 
32 	protected SnippetProvider[] providers;
33 
34 	protected void load()
35 	{
36 		config.stringBehavior = StringBehavior.source;
37 		providers.reserve(16);
38 		providers ~= plainSnippets = new PlainSnippetProvider();
39 		providers ~= smartSnippets = new SmartSnippetProvider();
40 		providers ~= dependencySnippets = new DependencyBasedSnippetProvider();
41 	}
42 
43 	/** 
44 	 * Params:
45 	 *   file = Filename to resolve dependencies relatively from.
46 	 *   code = Code to complete snippet in.
47 	 *   position = Byte offset of where to find scope in.
48 	 *
49 	 * Returns: a `SnippetInfo` object for all snippet information.
50 	 *
51 	 * `.loopScope` is set if a loop can be inserted at this position, Optionally
52 	 * with information about close ranges. Contains `SnippetLoopScope.init` if
53 	 * this is not a location where a loop can be inserted.
54 	 */
55 	SnippetInfo determineSnippetInfo(scope const(char)[] file, scope const(char)[] code, int position)
56 	{
57 		// each variable is 1
58 		// maybe more expensive lookups with DCD in the future
59 		enum LoopVariableAnalyzeMaxCost = 90;
60 
61 		scope tokens = getTokensForParser(cast(ubyte[]) code, config, &workspaced.stringCache);
62 		// TODO: binary search
63 		size_t loc;
64 		foreach (i, tok; tokens)
65 			if (tok.index >= position)
66 			{
67 				loc = i;
68 				break;
69 			}
70 
71 		auto leading = tokens[0 .. loc];
72 		foreach_reverse (t; leading)
73 		{
74 			if (t.type == tok!";")
75 				break;
76 
77 			// test for tokens semicolon closed statements where we should abort to avoid incomplete syntax
78 			if (t.type.among!(tok!"import", tok!"module"))
79 			{
80 				return SnippetInfo([SnippetLevel.global, SnippetLevel.other]);
81 			}
82 			else if (t.type.among!(tok!"=", tok!"+", tok!"-", tok!"*", tok!"/",
83 					tok!"%", tok!"^^", tok!"&", tok!"|", tok!"^", tok!"<<",
84 					tok!">>", tok!">>>", tok!"~", tok!"in"))
85 			{
86 				return SnippetInfo([SnippetLevel.global, SnippetLevel.value]);
87 			}
88 		}
89 
90 		scope parsed = parseModule(tokens, cast(string) file, &rba);
91 
92 		trace("determineSnippetInfo at ", position);
93 
94 		scope gen = new SnippetInfoGenerator(position);
95 		gen.variableStack.reserve(64);
96 		gen.visit(parsed);
97 
98 		gen.value.loopScope.supported = gen.value.level == SnippetLevel.method;
99 		if (gen.value.loopScope.supported)
100 		{
101 			int cost = 0;
102 			foreach_reverse (v; gen.variableStack)
103 			{
104 				if (fillLoopScopeInfo(gen.value.loopScope, v))
105 					break;
106 				if (++cost > LoopVariableAnalyzeMaxCost)
107 					break;
108 			}
109 		}
110 
111 		return gen.value;
112 	}
113 
114 	Future!(Snippet[]) getSnippets(scope const(char)[] file, scope const(char)[] code, int position)
115 	{
116 		mixin(gthreadsAsyncProxy!`getSnippetsBlocking(file, code, position)`);
117 	}
118 
119 	Snippet[] getSnippetsBlocking(scope const(char)[] file, scope const(char)[] code, int position)
120 	{
121 		auto futures = collectSnippets(file, code, position);
122 
123 		auto ret = appender!(Snippet[]);
124 		foreach (fut; futures)
125 			ret.put(fut.getBlocking());
126 		return ret.data;
127 	}
128 
129 	Snippet[] getSnippetsYield(scope const(char)[] file, scope const(char)[] code, int position)
130 	{
131 		auto futures = collectSnippets(file, code, position);
132 
133 		auto ret = appender!(Snippet[]);
134 		foreach (fut; futures)
135 			ret.put(fut.getYield());
136 		return ret.data;
137 	}
138 
139 	Future!Snippet resolveSnippet(scope const(char)[] file, scope const(char)[] code,
140 			int position, Snippet snippet)
141 	{
142 		foreach (provider; providers)
143 		{
144 			if (typeid(provider).name == snippet.providerId)
145 			{
146 				const info = determineSnippetInfo(file, code, position);
147 				return provider.resolveSnippet(instance, file, code, position, info, snippet);
148 			}
149 		}
150 
151 		return Future!Snippet.fromResult(snippet);
152 	}
153 
154 	Future!string format(scope const(char)[] snippet, string[] arguments = [])
155 	{
156 		mixin(gthreadsAsyncProxy!`formatSync(snippet, arguments)`);
157 	}
158 
159 	/// Will format the code passed in synchronously using dfmt. Might take a short moment on larger documents.
160 	/// Returns: the formatted code as string or unchanged if dfmt is not active
161 	string formatSync(scope const(char)[] snippet, string[] arguments = [])
162 	{
163 		if (!has!DfmtComponent)
164 			return snippet.idup;
165 
166 		auto dfmt = get!DfmtComponent;
167 
168 		auto tmp = appender!string;
169 
170 		scope const(char)[][string] tokens;
171 
172 		ptrdiff_t dollar, last;
173 		while (true)
174 		{
175 			dollar = snippet.indexOfAny(`$\`, last);
176 			if (dollar == -1)
177 			{
178 				tmp ~= snippet[last .. $];
179 				break;
180 			}
181 
182 			tmp ~= snippet[last .. dollar];
183 			last = dollar + 1;
184 			if (last >= snippet.length)
185 				break;
186 			if (snippet[dollar] == '\\')
187 			{
188 				tmp ~= snippet[dollar + 1];
189 				last = dollar + 2;
190 			}
191 			else
192 			{
193 				string key = "__WspD_Snp_" ~ dollar.to!string;
194 				const(char)[] str;
195 
196 				if (snippet[dollar + 1] == '{')
197 				{
198 					ptrdiff_t i = dollar + 2;
199 					int depth = 1;
200 					while (true)
201 					{
202 						auto next = snippet.indexOfAny(`\{}`, i);
203 						if (next == -1)
204 						{
205 							i = snippet.length;
206 							break;
207 						}
208 
209 						if (snippet[next] == '\\')
210 							i = next + 2;
211 						else
212 						{
213 							if (snippet[next] == '{')
214 								depth++;
215 							else if (snippet[next] == '}')
216 								depth--;
217 							else
218 								assert(false);
219 
220 							i = next + 1;
221 						}
222 
223 						if (depth == 0)
224 							break;
225 					}
226 					str = snippet[dollar .. i];
227 					last = i;
228 
229 					if (str.length > 5 || snippet[last .. $].stripLeft.startsWith(";", ".", "{"))
230 					{
231 						// let's insert some token in here instead of a comment because there is probably some default content
232 						if (str[0 .. $ - 1].endsWith(';'))
233 							key ~= ';';
234 					}
235 					else
236 					{
237 						// empty default, put in comment
238 						key = "/+++" ~ key ~ "+++/";
239 					}
240 				}
241 				else
242 				{
243 					size_t end = dollar + 1;
244 
245 					if (snippet[dollar + 1].isDigit)
246 					{
247 						while (end < snippet.length && snippet[end].isDigit)
248 							end++;
249 					}
250 					else
251 					{
252 						while (end < snippet.length && (snippet[end].isAlphaNum || snippet[end] == '_'))
253 							end++;
254 					}
255 
256 					str = snippet[dollar .. end];
257 					last = end;
258 					if (snippet[last .. $].stripLeft.startsWith(";", ".", "{"))
259 					{
260 						// keep value thing as token
261 					}
262 					else
263 					{
264 						// primitive placeholder as comment
265 						key = "/+++" ~ key ~ "+++/";
266 					}
267 				}
268 
269 				tokens[key] = str;
270 				tmp ~= key;
271 			}
272 		}
273 
274 		auto res = dfmt.formatSync(tmp.data, arguments);
275 
276 		foreach (key, value; tokens)
277 		{
278 			// TODO: replacing using aho-corasick would be far more efficient but there is nothing like that in phobos
279 			res = res.replace(key, value);
280 		}
281 
282 		if (res.endsWith("\r\n") && !snippet.endsWith('\n'))
283 			res.length -= 2;
284 		else if (res.endsWith('\n') && !snippet.endsWith('\n'))
285 			res.length--;
286 
287 		if (res.endsWith(";\n\n$0"))
288 			res = res[0 .. $ - "\n$0".length] ~ "$0";
289 		else if (res.endsWith(";\r\n\r\n$0"))
290 			res = res[0 .. $ - "\r\n$0".length] ~ "$0";
291 
292 		return res;
293 	}
294 
295 	/// Adds snippets which complete conditionally based on dub dependencies being present.
296 	/// This function affects the global configuration of all instances.
297 	/// Params:
298 	///   requiredDependencies = The dependencies which must be present in order for this snippet to show up.
299 	///   snippet = The snippet to suggest when the required dependencies are matched.
300 	void addDependencySnippet(string[] requiredDependencies, PlainSnippet snippet)
301 	{
302 		// maybe application global change isn't such a good idea? Current config system seems too inefficient for this.
303 		dependencySnippets.addSnippet(requiredDependencies, snippet);
304 	}
305 
306 private:
307 	Future!(Snippet[])[] collectSnippets(scope const(char)[] file,
308 			scope const(char)[] code, int position)
309 	{
310 		const inst = instance;
311 		const info = determineSnippetInfo(file, code, position);
312 		auto futures = appender!(Future!(Snippet[])[]);
313 		foreach (provider; providers)
314 			futures.put(provider.provideSnippets(inst, file, code, position, info));
315 		return futures.data;
316 	}
317 
318 	RollbackAllocator rba;
319 	LexerConfig config;
320 }
321 
322 ///
323 enum SnippetLevel
324 {
325 	/// Outside of functions or types, possibly inside templates
326 	global,
327 	/// Inside interfaces, classes, structs or unions
328 	type,
329 	/// Inside method body
330 	method,
331 	/// inside a variable value, argument call, default value or similar
332 	value,
333 	/// Other scope types (for example outside of braces but after a function definition or some other invalid syntax place)
334 	other
335 }
336 
337 ///
338 struct SnippetLoopScope
339 {
340 	/// true if an loop expression can be inserted at this point
341 	bool supported;
342 	/// true if we know we are iterating over a string (possibly needing unicode decoding) or false otherwise
343 	bool stringIterator;
344 	/// Explicit type to use when iterating or null if none is known
345 	string type;
346 	/// Best variable to iterate over or null if none was found
347 	string iterator;
348 	/// Number of keys to iterate over
349 	int numItems = 1;
350 }
351 
352 ///
353 struct SnippetInfo
354 {
355 	/// Levels this snippet location has gone through, latest one being the last
356 	SnippetLevel[] stack = [SnippetLevel.global];
357 	/// Information about snippets using loop context
358 	SnippetLoopScope loopScope;
359 
360 	/// Current snippet scope level of the location
361 	SnippetLevel level() const @property
362 	{
363 		return stack.length ? stack[$ - 1] : SnippetLevel.other;
364 	}
365 }
366 
367 ///
368 interface SnippetProvider
369 {
370 	Future!(Snippet[]) provideSnippets(scope const WorkspaceD.Instance instance,
371 			scope const(char)[] file, scope const(char)[] code, int position, const SnippetInfo info);
372 
373 	Future!Snippet resolveSnippet(scope const WorkspaceD.Instance instance,
374 			scope const(char)[] file, scope const(char)[] code, int position,
375 			const SnippetInfo info, Snippet snippet);
376 }
377 
378 /// Snippet to insert
379 struct Snippet
380 {
381 	/// Internal ID for resolving this snippet
382 	string id, providerId;
383 	/// User-defined data for helping resolving this snippet
384 	JSONValue data;
385 	/// Label for this snippet
386 	string title;
387 	/// Shortcut to type for this snippet
388 	string shortcut;
389 	/// Markdown documentation for this snippet
390 	string documentation;
391 	/// Plain text to insert assuming global level indentation.
392 	string plain;
393 	/// Text with interactive snippet locations to insert assuming global indentation.
394 	string snippet;
395 	/// true if this snippet can be used as-is
396 	bool resolved;
397 }
398 
399 unittest
400 {
401 	scope backend = new WorkspaceD();
402 	auto workspace = makeTemporaryTestingWorkspace;
403 	auto instance = backend.addInstance(workspace.directory);
404 	backend.register!SnippetsComponent;
405 	backend.register!DfmtComponent;
406 	SnippetsComponent snippets = backend.get!SnippetsComponent(workspace.directory);
407 
408 	auto args = ["--indent_style", "tab"];
409 
410 	auto res = snippets.formatSync("void main(${1:string[] args}) {\n\t$0\n}", args);
411 	shouldEqual(res, "void main(${1:string[] args})\n{\n\t$0\n}");
412 
413 	res = snippets.formatSync("class ${1:MyClass} {\n\t$0\n}", args);
414 	shouldEqual(res, "class ${1:MyClass}\n{\n\t$0\n}");
415 
416 	res = snippets.formatSync("enum ${1:MyEnum} = $2;\n$0", args);
417 	shouldEqual(res, "enum ${1:MyEnum} = $2;\n$0");
418 
419 	res = snippets.formatSync("import ${1:std};\n$0", args);
420 	shouldEqual(res, "import ${1:std};\n$0");
421 }