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