1 module dietc.complete;
2 
3 import dietc.lexer;
4 import dietc.parser;
5 
6 import std.algorithm;
7 import std.array;
8 import std..string;
9 import std.uni;
10 import std.meta;
11 
12 /// Delegate to provide other files that are being extended
13 alias FileProvider = DietInput delegate(string name);
14 
15 enum CompletionType
16 {
17 	none,
18 	tag,
19 	attribute,
20 	value,
21 	reference,
22 	cssName,
23 	cssValue,
24 	d,
25 	meta
26 }
27 
28 struct Completion
29 {
30 	CompletionType type;
31 	string text;
32 	string definition;
33 	string documentation;
34 
35 	string referenceFile;
36 	size_t[2] referenceRange;
37 	bool preselected;
38 
39 	auto preselect()
40 	{
41 		preselected = true;
42 		return this;
43 	}
44 
45 	static immutable const(Completion)[] completeD = [Completion(CompletionType.d, "", "<d source>")];
46 }
47 
48 struct TagInfo
49 {
50 	struct Attribute
51 	{
52 		string name;
53 		CompletionSource completion;
54 	}
55 
56 	string tag;
57 	Attribute[] attributes;
58 }
59 
60 private const(TagInfo)[] parseTagInfos(string info)
61 {
62 	const(TagInfo)[] ret;
63 	const(TagInfo.Attribute)[] attributeBase;
64 	CompletionSource[string] enumCompletions;
65 
66 	auto parseAttribute(string attr)
67 	{
68 		auto colon = attr.indexOf(":");
69 		if (colon == -1)
70 			throw new Exception("Malformed attribute: " ~ attr);
71 		TagInfo.Attribute ret;
72 		ret.name = attr[0 .. colon];
73 		auto value = attr[colon + 1 .. $];
74 		auto dot = value.indexOf('.');
75 
76 		if (auto exist = value in enumCompletions)
77 			ret.completion = *exist;
78 		else if (dot != -1)
79 			ret.completion = new AttributeValueByTagNameComplete(value[0 .. dot], value[dot + 1 .. $]);
80 		else
81 			throw new Exception("Unknown attribute value " ~ value);
82 
83 		return cast(const) ret;
84 	}
85 
86 	foreach (line; info.lineSplitter)
87 	{
88 		line = line.strip;
89 		if (line.startsWith("//") || !line.length)
90 			continue;
91 		if (line.startsWith("e "))
92 		{
93 			line = line[1 .. $].stripLeft;
94 			auto larr = line.indexOf("<");
95 			if (larr == -1)
96 				throw new Exception("Malformed enum line: " ~ line);
97 			auto name = line[0 .. larr].stripRight;
98 			auto values = line[larr + 1 .. $].stripLeft.splitter;
99 			enumCompletions[name] = new EnumComplete(values.map!(a => Completion(CompletionType.value, a)).array);
100 		}
101 		else if (line.startsWith("t "))
102 		{
103 			line = line[1 .. $].stripLeft;
104 			auto larr = line.indexOf("<");
105 			if (larr == -1)
106 				throw new Exception("Malformed enum line: " ~ line);
107 			auto name = line[0 .. larr].stripRight;
108 			auto attrs = attributeBase;
109 			foreach (attr; line[larr + 1 .. $].stripLeft.splitter)
110 				attrs ~= parseAttribute(attr);
111 			ret ~= const TagInfo(name, attrs);
112 		}
113 		else if (line.startsWith("tbase "))
114 		{
115 			line = line["tbase".length .. $].stripLeft;
116 			attributeBase = null;
117 			foreach (attr; line.splitter)
118 				attributeBase ~= parseAttribute(attr);
119 		}
120 		else throw new Exception("Invalid line " ~ line);
121 	}
122 	return ret;
123 }
124 
125 __gshared const(TagInfo)[] tagInfos;
126 shared static this()
127 {
128 	tagInfos = import("html.txt").parseTagInfos;
129 }
130 
131 static immutable Completion[] tagCompletions = import("tags.txt").strip
132 	.splitLines.map!(a => Completion(CompletionType.tag, a)).array;
133 
134 //dfmt off
135 // https://github.com/rejectedsoftware/diet-ng/blob/f65a31def40f40cba2bf03a8f2093821e28a26d3/source/diet/html.d#L428
136 static immutable Completion[] doctypeCompletions = [
137 	Completion(CompletionType.value, "html", `<!DOCTYPE html>`).preselect,
138 	Completion(CompletionType.value, "xml", `<?xml version="1.0" encoding="utf-8" ?>`),
139 	Completion(CompletionType.value, "transitional", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">`),
140 	Completion(CompletionType.value, "strict", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">`),
141 	Completion(CompletionType.value, "frameset", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Frameset//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd">`),
142 	Completion(CompletionType.value, "1.1", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">`),
143 	Completion(CompletionType.value, "basic", `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN" "http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd">`),
144 	Completion(CompletionType.value, "mobile", `<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN" "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">`),
145 ];
146 //dfmt on
147 
148 interface CompletionSource
149 {
150 	const(Completion)[] complete(string identifier, AST[] context, DietComplete engine, size_t offset);
151 }
152 
153 class EnumComplete : CompletionSource
154 {
155 	const Completion[] available;
156 
157 	this(const Completion[] available)
158 	{
159 		this.available = available;
160 	}
161 
162 	const(Completion)[] complete(string identifier, AST[], DietComplete, size_t) const
163 	{
164 		return available.filter!(a => a.text.asLowerCase.startsWith(identifier.asLowerCase)).array;
165 	}
166 }
167 
168 /// Finds attribute values from other tags based on a tag name such as "find id values from table elements".
169 class AttributeValueByTagNameComplete : CompletionSource
170 {
171 	string attribute, tag;
172 
173 	this(string attribute, string tag)
174 	{
175 		this.attribute = attribute;
176 		this.tag = tag;
177 	}
178 
179 	const(Completion)[] complete(string identifier, AST[] context, DietComplete, size_t) const
180 	{
181 		return null;
182 	}
183 }
184 
185 class AttributeNameComplete : CompletionSource
186 {
187 	const TagInfo[] tagInfos;
188 
189 	this(in TagInfo[] tagInfos)
190 	{
191 		this.tagInfos = tagInfos;
192 	}
193 
194 	const(Completion)[] complete(string identifier, AST[] context, DietComplete, size_t) const
195 	{
196 		TagNode tag;
197 		foreach_reverse (node; context)
198 			if (cast(TagNode) node)
199 			{
200 				tag = cast(TagNode) node;
201 				break;
202 			}
203 
204 		if (tag)
205 		{
206 			foreach (info; tagInfos)
207 			{
208 				if (sicmp(info.tag, tag.name) == 0)
209 				{
210 					const(Completion)[] completion;
211 					foreach (attr; info.attributes)
212 						if (attr.name.startsWith(identifier.asLowerCase))
213 							completion ~= Completion(CompletionType.attribute, attr.name);
214 					return completion;
215 				}
216 			}
217 		}
218 		return null;
219 	}
220 }
221 
222 class AttributeValueComplete : CompletionSource
223 {
224 	const TagInfo[] tagInfos;
225 
226 	this(in TagInfo[] tagInfos)
227 	{
228 		this.tagInfos = tagInfos;
229 	}
230 
231 	const(Completion)[] complete(string identifier, AST[] context, DietComplete engine, size_t offset) const
232 	{
233 		TagNode tag;
234 		TagNode.AttributeAST attribute;
235 		foreach_reverse (node; context)
236 		{
237 			if (cast(TagNode.AttributeAST) node)
238 				attribute = cast(TagNode.AttributeAST) node;
239 
240 			if (cast(TagNode) node)
241 			{
242 				tag = cast(TagNode) node;
243 				break;
244 			}
245 		}
246 
247 		identifier = identifier.reduceToLastIdentifier;
248 
249 		if (tag)
250 		{
251 			foreach (info; tagInfos)
252 			{
253 				if (sicmp(info.tag, tag.name) == 0)
254 				{
255 					Completion[] completion;
256 					foreach (attr; info.attributes)
257 						if (sicmp(attr.name, attribute.name) == 0)
258 							completion ~= (cast()attr.completion).complete(identifier, context, engine, offset);
259 					return cast(const(Completion)[]) completion.sort!"a.text < b.text".uniq!"a.text == b.text".array;
260 				}
261 			}
262 		}
263 		return null;
264 	}
265 }
266 
267 class DietComplete
268 {
269 	FileProvider provider;
270 	ASTParser parser;
271 	EnumComplete tags;
272 	EnumComplete doctypes;
273 	AttributeNameComplete attributeNames;
274 	AttributeValueComplete attributeValues;
275 
276 	this(string file)
277 	{
278 		import std.path : dirName;
279 		this(DietInput.fromFile(file), defaultFileProvider(dirName(file)));
280 	}
281 
282 	this(DietInput root, FileProvider provider)
283 	{
284 		tags = new EnumComplete(tagCompletions);
285 		doctypes = new EnumComplete(doctypeCompletions);
286 		attributeNames = new AttributeNameComplete(tagInfos);
287 		attributeValues = new AttributeValueComplete(tagInfos);
288 
289 		this.provider = provider;
290 		root.reset();
291 		parser = ASTParser(root);
292 		parser.parseDocument();
293 	}
294 
295 	void reparse(string content)
296 	{
297 		parser.input.code = content;
298 		parser.input.reset();
299 		parser.parseDocument();
300 	}
301 
302 	static FileProvider defaultFileProvider(string dir)
303 	{
304 		return (name) {
305 			import std.file : exists;
306 			import std.path : chainPath, withExtension;
307 
308 			if (exists(chainPath(dir, name.withExtension(".dt"))))
309 				return DietInput.fromFile(chainPath(dir, name.withExtension(".dt")));
310 			else
311 				return DietInput.init;
312 		};
313 	}
314 
315 	const(Completion)[] completeAt(size_t offset)
316 	{
317 		auto tree = parser.searchAST(offset);
318 
319 		auto contentLess = tree;
320 		if (cast(Document) contentLess[0])
321 			while (cast(TextLine) contentLess[$ - 1] || cast(TextLine.PartAST) contentLess[$ - 1])
322 				contentLess.length--;
323 		assert(contentLess.length >= 1);
324 
325 		if (cast(DStatement) tree[$ - 1] || cast(Assignment) tree[$ - 1] || cast(RawAssignment) tree[$ - 1])
326 		{
327 			auto stmt = cast(IStringContainer) tree[$ - 1];
328 			if (offset.withinRange([stmt.token.range[0] + 1, stmt.token.range[0] + 1 + stmt.content.length]))
329 				return Completion.completeD;
330 			else
331 				return null; // before "-" character or in newline at end
332 		}
333 
334 		if (auto expr = cast(Expression) tree[$ - 1])
335 		{
336 			auto code = expr.content;
337 			if (!code.all!isNumber && !code.isPlainString)
338 				return Completion.completeD;
339 		}
340 
341 		if (tree.length >= 2)
342 		{
343 			if (auto expr = cast(Expression) tree[$ - 1])
344 				if (auto attr = cast(TagNode.AttributeAST) tree[$ - 2])
345 				{
346 					string exprCode = parser.input.read([expr.token.range[0], offset]);
347 					return attributeValues.complete(exprCode, tree, this, offset);
348 				}
349 		}
350 
351 		if (tree.length >= 3)
352 		{
353 			if (auto part = cast(TextLine.PartAST) tree[$ - 1])
354 				if (auto line = cast(TextLine) tree[$ - 2])
355 					if (auto tag = cast(TagNode) tree[$ - 3])
356 						if (line._parts.length == 1 && part.part.raw.length)
357 						{
358 							string text = part.part.raw[0 .. offset - part.token.range[0]];
359 							if (tag.name == "doctype")
360 								return doctypes.complete(text, tree, this, offset);
361 						}
362 		}
363 
364 		if (auto tag = cast(TagNode) contentLess[$ - 1])
365 		{
366 			if (offset.withinRange(tag.tag.range))
367 				return tags.complete(parser.input.read([tag.tag.range[0], offset]), contentLess, this, offset);
368 			else if (offset.withinRange(tag.attributesRange)) // somewhere random in attributes but not in name or value
369 				return attributeNames.complete("", contentLess, this, offset);
370 		}
371 
372 		if (contentLess.length >= 2)
373 		{
374 			if (auto tag = cast(TagNode) contentLess[$ - 2])
375 				if (auto attr = cast(TagNode.AttributeAST) contentLess[$ - 1])
376 					if (offset.withinRange(attr.token.range))
377 						return attributeNames.complete(parser.input.read([attr.token.range[0], offset]), contentLess, this, offset);
378 
379 			if (auto text = cast(TextLine) contentLess[$ - 1]) // must be an empty textline because otherwise a PartAST would be here
380 				if (auto tag = cast(TagNode) contentLess[$ - 2])
381 				{
382 					if (tag.name == "doctype")
383 						return doctypes.complete("", contentLess, this, offset);
384 				}
385 		}
386 
387 		if (tree.length == 1)
388 		{
389 			auto ret = tags.complete("", tree, this, offset);
390 			if (cast(Document)tree[0] && (cast(Document)tree[0])._children.length == 0)
391 				ret ~= Completion(CompletionType.meta, "doctype").preselect;
392 			return ret;
393 		}
394 
395 		return null;
396 	}
397 }
398 
399 void extractD(DietComplete complete, size_t offset, out string code, out size_t codeOffset)
400 {
401 	return complete.parser.root.extractD(offset, code, codeOffset);
402 }
403 
404 void extractD(AST root, size_t offset, out string code, out size_t codeOffset)
405 {
406 	codeOffset = size_t.max;
407 	class CodeVisitorImpl : ASTVisitor
408 	{
409 		override void visit(DStatement stmt) in (stmt !is null)
410 		{
411 			if (offset.withinRange(stmt.token.range))
412 				codeOffset = code.length + (offset - (stmt.token.range[0] + stmt.token.content.length));
413 			code ~= stmt.content;
414 			if (stmt.children.length)
415 			{
416 				code ~= "{/*children*/";
417 				stmt.accept(this);
418 				code ~= "}";
419 			}
420 		}
421 
422 		static foreach (T; AliasSeq!(Expression, Assignment, RawAssignment))
423 			override void visit(T expr) in (expr !is null)
424 			{
425 				code ~= "__diet_value(";
426 				if (offset.withinRange(expr.token.range))
427 					codeOffset = code.length + (offset - expr.token.range[0] - expr.token.content.length);
428 				code ~= expr.content;
429 				code ~= ");";
430 			}
431 
432 		override void visit(Comment comment) in (comment !is null)
433 		{
434 		}
435 
436 		override void visit(HiddenComment comment) in (comment !is null)
437 		{
438 		}
439 
440 		override void visit(Document doc) in (doc !is null)
441 		{
442 			code ~= "void __diet_document() {";
443 			doc.accept(this);
444 			code ~= "}";
445 		}
446 
447 		static foreach (T; AliasSeq!(TagNode, TagNode.AttributeAST))
448 			override void visit(T ast) in (ast !is null)
449 			{
450 				code ~= "{";
451 				ast.accept(this);
452 				code ~= "}";
453 			}
454 
455 		alias visit = ASTVisitor.visit;
456 	}
457 
458 	new CodeVisitorImpl().visit(root);
459 }
460 
461 bool isPlainString(string code)
462 {
463 	if (!code.length)
464 		return false;
465 	auto quote = code[0];
466 	if (quote != '\'' && quote != '"' && quote != '`')
467 		return false;
468 	if (code[$ - 1] != quote)
469 		return false;
470 	// TODO: better string check
471 	return true;
472 }
473 
474 string reduceToLastIdentifier(string identifier)
475 {
476 	foreach_reverse (i, c; identifier)
477 	{
478 		if (c.isNumber || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_' || c == '-')
479 			continue;
480 		return identifier[i + 1 .. $];
481 	}
482 	return identifier;
483 }
484 
485 unittest
486 {
487 	import std.conv;
488 
489 	const(Completion)[] testComplete(string text, size_t at,
490 			string file = __FILE__ ~ ":" ~ __LINE__.to!string)
491 	{
492 		DietInput input;
493 		input.file = file;
494 		input.code = text;
495 		return new DietComplete(input, cast(FileProvider)(name) {
496 			assert(false, "Can't import " ~ name ~ " in test");
497 		}).completeAt(at);
498 	}
499 
500 	assert(testComplete("a", 0).canFind!(a => a.text == "textarea"));
501 	assert(testComplete("t", 1).canFind!(a => a.text == "textarea"));
502 	assert(testComplete("", 0).canFind!(a => a.text == "textarea"));
503 	assert(testComplete("div\n\t", 5).canFind!(a => a.text == "textarea"));
504 }