1 /**
2 	Generic Diet format parser.
3 
4 	Performs generic parsing of a Diet template file. The resulting AST is
5 	agnostic to the output format context in which it is used. Format
6 	specific constructs, such as inline code or special tags, are parsed
7 	as-is without any preprocessing.
8 
9 	The supported features of the are:
10 	$(UL
11 		$(LI string interpolations)
12 		$(LI assignment expressions)
13 		$(LI blocks/extensions)
14 		$(LI includes)
15 		$(LI text paragraphs)
16 		$(LI translation annotations)
17 		$(LI class and ID attribute shortcuts)
18 	)
19 */
20 module diet.parser;
21 
22 import diet.dom;
23 import diet.defs;
24 import diet.input;
25 import diet.internal.string;
26 
27 import memutils.vector;
28 import memutils.scoped;
29 nothrow:
30 
31 version(unittest)
32 {
33 	// this is needed to make unittests safe for comparison. Due to
34 	// Object.opCmp being @system, we cannot fix this here.
35 	bool nodeEq(Node[] arr1, Node[] arr2) @trusted { return arr1 == arr2; }
36 }
37 
38 
39 /** Parses a Diet template document and outputs the resulting DOM tree.
40 
41 	The overload that takes a list of files will automatically resolve
42 	includes and extensions.
43 
44 	Params:
45 		TR = An optional translation function that takes and returns a string.
46 			This function will be invoked whenever node text contents need
47 			to be translated at compile tile (for the `&` node suffix).
48 		text = For the single-file overload, specifies the contents of the Diet
49 			template.
50 		filename = For the single-file overload, specifies the file name that
51 			is displayed in error messages and stored in the DOM `Location`s.
52 		files = A full set of Diet template files. All files referenced in
53 			includes or extension directives must be present.
54 
55 	Returns:
56 		The list of parsed root nodes is returned.
57 */
58 Document parseDiet(alias TR = identity)(string text, string filename = "string")
59 	if (is(typeof(TR(string.init)) == string) || is(typeof(TR(string.init, string.init)) == string))
60 {
61 	Array!InputFile f_arr;
62 	InputFile f;
63 	f.name[] = filename;
64 	f.contents[] = text;
65 	f_arr ~= f;
66 	return parseDiet!TR(f_arr);
67 }
68 
69 /// Ditto
70 Document parseDiet(alias TR = identity, F)(F files)
71 	if (is(typeof(TR(string.init)) == string) || is(typeof(TR(string.init, string.init)) == string))
72 {
73 	import diet.traits;
74 
75 	assert(files.length > 0, "Empty set of input files");
76 
77 	Vector!FileInfo parsed_files;
78 	foreach (f; files){
79 		parsed_files ~= FileInfo(Array!char(f.name[]), Array!Node(parseDietRaw!TR(f)));
80 	}
81 	Vector!BlockInfo blocks;
82 	auto nodes = parseDietWithExtensions(parsed_files[], 0, blocks, null);
83 	return Document(nodes[]);
84 }
85 
86 @safe unittest { // test basic functionality
87 	Location ln(int l) @safe { return Location("string", l); }
88 
89 	// simple node
90 	assert(parseDiet("test").nodes.nodeEq([
91 		new Node(ln(0), "test")
92 	]));
93 
94 	// nested nodes
95 	assert(parseDiet("foo\n  bar").nodes.nodeEq([
96 		new Node(ln(0), "foo", null, [
97 			NodeContent.tag(new Node(ln(1), "bar"))
98 		])
99 	]));
100 
101 	// node with id and classes
102 	assert(parseDiet("test#id.cls1.cls2").nodes.nodeEq([
103 		new Node(ln(0), "test", [
104 			Attribute(ln(0), "id", [AttributeContent.text("id")]),
105 			Attribute(ln(0), "class", [AttributeContent.text("cls1")]),
106 			Attribute(ln(0), "class", [AttributeContent.text("cls2")])
107 		])
108 	]));
109 	assert(parseDiet("test.cls1#id.cls2").nodes.nodeEq([ // issue #9
110 		new Node(ln(0), "test", [
111 			Attribute(ln(0), "class", [AttributeContent.text("cls1")]),
112 			Attribute(ln(0), "id", [AttributeContent.text("id")]),
113 			Attribute(ln(0), "class", [AttributeContent.text("cls2")])
114 		])
115 	]));
116 
117 	// empty tag name (only class)
118 	assert(parseDiet(".foo").nodes.nodeEq([
119 		new Node(ln(0), "", [
120 			Attribute(ln(0), "class", [AttributeContent.text("foo")])
121 		])
122 	]));
123 	assert(parseDiet("a.download-button\n\t.bs-hbtn.right.black").nodes.nodeEq([
124 		new Node(ln(0), "a", [
125 			Attribute(ln(0), "class", [AttributeContent.text("download-button")]),
126 		], [
127 			NodeContent.tag(new Node(ln(1), "", [
128 				Attribute(ln(1), "class", [AttributeContent.text("bs-hbtn")]),
129 				Attribute(ln(1), "class", [AttributeContent.text("right")]),
130 				Attribute(ln(1), "class", [AttributeContent.text("black")])
131 			]))
132 		])
133 	]));
134 
135 	// empty tag name (only id)
136 	assert(parseDiet("#foo").nodes.nodeEq([
137 		new Node(ln(0), "", [
138 			Attribute(ln(0), "id", [AttributeContent.text("foo")])
139 		])
140 	]));
141 
142 	// node with attributes
143 	assert(parseDiet("test(foo1=\"bar\", foo2=2+3)").nodes.nodeEq([
144 		new Node(ln(0), "test", [
145 			Attribute(ln(0), "foo1", [AttributeContent.text("bar")]),
146 			Attribute(ln(0), "foo2", [AttributeContent.interpolation("2+3")])
147 		])
148 	]));
149 
150 	// node with pure text contents
151 	assert(parseDiet("foo.\n\thello\n\t   world").nodes.nodeEq([
152 		new Node(ln(0), "foo", null, [
153 			NodeContent.text("hello", ln(1)),
154 			NodeContent.text("\n   world", ln(2))
155 		], NodeAttribs.textNode)
156 	]));
157 	assert(parseDiet("foo.\n\thello\n\n\t   world").nodes.nodeEq([
158 		new Node(ln(0), "foo", null, [
159 			NodeContent.text("hello", ln(1)),
160 			NodeContent.text("\n", ln(2)),
161 			NodeContent.text("\n   world", ln(3))
162 		], NodeAttribs.textNode)
163 	]));
164 
165 	// translated text
166 	assert(parseDiet("foo& test").nodes.nodeEq([
167 		new Node(ln(0), "foo", null, [
168 			NodeContent.text("test", ln(0))
169 		], NodeAttribs.translated, "test")
170 	]));
171 
172 	// interpolated text
173 	assert(parseDiet("foo hello #{\"world\"} #bar \\#{baz}").nodes.nodeEq([
174 		new Node(ln(0), "foo", null, [
175 			NodeContent.text("hello ", ln(0)),
176 			NodeContent.interpolation(`"world"`, ln(0)),
177 			NodeContent.text(" #bar #{baz}", ln(0))
178 		])
179 	]));
180 
181 	// expression
182 	assert(parseDiet("foo= 1+2").nodes.nodeEq([
183 		new Node(ln(0), "foo", null, [
184 			NodeContent.interpolation(`1+2`, ln(0)),
185 		])
186 	]));
187 
188 	// expression with empty tag name
189 	assert(parseDiet("= 1+2").nodes.nodeEq([
190 		new Node(ln(0), "", null, [
191 			NodeContent.interpolation(`1+2`, ln(0)),
192 		])
193 	]));
194 
195 	// raw expression
196 	assert(parseDiet("foo!= 1+2").nodes.nodeEq([
197 		new Node(ln(0), "foo", null, [
198 			NodeContent.rawInterpolation(`1+2`, ln(0)),
199 		])
200 	]));
201 
202 	// interpolated attribute text
203 	assert(parseDiet("foo(att='hello #{\"world\"} #bar')").nodes.nodeEq([
204 		new Node(ln(0), "foo", [
205 			Attribute(ln(0), "att", [
206 				AttributeContent.text("hello "),
207 				AttributeContent.interpolation(`"world"`),
208 				AttributeContent.text(" #bar")
209 			])
210 		])
211 	]));
212 
213 	// attribute expression
214 	assert(parseDiet("foo(att=1+2)").nodes.nodeEq([
215 		new Node(ln(0), "foo", [
216 			Attribute(ln(0), "att", [
217 				AttributeContent.interpolation(`1+2`),
218 			])
219 		])
220 	]));
221 
222 	// multiline attribute expression
223 	assert(parseDiet("foo(\n\tatt=1+2,\n\tfoo=bar\n)").nodes.nodeEq([
224 		new Node(ln(0), "foo", [
225 			Attribute(ln(0), "att", [
226 				AttributeContent.interpolation(`1+2`),
227 			]),
228 			Attribute(ln(0), "foo", [
229 				AttributeContent.interpolation(`bar`),
230 			])
231 		])
232 	]));
233 
234 	// special nodes
235 	assert(parseDiet("//comment").nodes.nodeEq([
236 		new Node(ln(0), Node.SpecialName.comment, null, [NodeContent.text("comment", ln(0))], NodeAttribs.rawTextNode)
237 	]));
238 	assert(parseDiet("//-hide").nodes.nodeEq([
239 		new Node(ln(0), Node.SpecialName.hidden, null, [NodeContent.text("hide", ln(0))], NodeAttribs.rawTextNode)
240 	]));
241 	assert(parseDiet("!!! 5").nodes.nodeEq([
242 		new Node(ln(0), "doctype", null, [NodeContent.text("5", ln(0))])
243 	]));
244 	assert(parseDiet("<inline>").nodes.nodeEq([
245 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("<inline>", ln(0))])
246 	]));
247 	assert(parseDiet("|text").nodes.nodeEq([
248 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("text", ln(0))])
249 	]));
250 	assert(parseDiet("|text\n").nodes.nodeEq([
251 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("text", ln(0))])
252 	]));
253 	assert(parseDiet("| text\n").nodes.nodeEq([
254 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("text", ln(0))])
255 	]));
256 	assert(parseDiet("|.").nodes.nodeEq([
257 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text(".", ln(0))])
258 	]));
259 	assert(parseDiet("|:").nodes.nodeEq([
260 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text(":", ln(0))])
261 	]));
262 	assert(parseDiet("|&x").nodes.nodeEq([
263 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("x", ln(0))], NodeAttribs.translated, "x")
264 	]));
265 	assert(parseDiet("-if(x)").nodes.nodeEq([
266 		new Node(ln(0), Node.SpecialName.code, null, [NodeContent.text("if(x)", ln(0))])
267 	]));
268 	assert(parseDiet("-if(x)\n\t|bar").nodes.nodeEq([
269 		new Node(ln(0), Node.SpecialName.code, null, [
270 			NodeContent.text("if(x)", ln(0)),
271 			NodeContent.tag(new Node(ln(1), Node.SpecialName.text, null, [
272 				NodeContent.text("bar", ln(1))
273 			]))
274 		])
275 	]));
276 	assert(parseDiet(":foo\n\tbar").nodes.nodeEq([
277 		new Node(ln(0), ":", [Attribute(ln(0), "filterChain", [AttributeContent.text("foo")])], [
278 			NodeContent.text("bar", ln(1))
279 		], NodeAttribs.textNode)
280 	]));
281 	assert(parseDiet(":foo :bar baz").nodes.nodeEq([
282 		new Node(ln(0), ":", [Attribute(ln(0), "filterChain", [AttributeContent.text("foo bar")])], [
283 			NodeContent.text("baz", ln(0))
284 		], NodeAttribs.textNode)
285 	]));
286 	assert(parseDiet(":foo\n\t:bar baz").nodes.nodeEq([
287 		new Node(ln(0), ":", [Attribute(ln(0), "filterChain", [AttributeContent.text("foo")])], [
288 			NodeContent.text(":bar baz", ln(1))
289 		], NodeAttribs.textNode)
290 	]));
291 	assert(parseDiet(":foo\n\tbar\n\t\t:baz").nodes.nodeEq([
292 		new Node(ln(0), ":", [Attribute(ln(0), "filterChain", [AttributeContent.text("foo")])], [
293 			NodeContent.text("bar", ln(1)),
294 			NodeContent.text("\n\t:baz", ln(2))
295 		], NodeAttribs.textNode)
296 	]));
297 
298 	// nested nodes
299 	assert(parseDiet("a: b").nodes.nodeEq([
300 		new Node(ln(0), "a", null, [
301 			NodeContent.tag(new Node(ln(0), "b"))
302 		])
303 	]));
304 
305 	assert(parseDiet("a: b\n\tc\nd").nodes.nodeEq([
306 		new Node(ln(0), "a", null, [
307 			NodeContent.tag(new Node(ln(0), "b", null, [
308 				NodeContent.tag(new Node(ln(1), "c"))
309 			]))
310 		]),
311 		new Node(ln(2), "d")
312 	]));
313 
314 	// inline nodes
315 	assert(parseDiet("a #[b]").nodes.nodeEq([
316 		new Node(ln(0), "a", null, [
317 			NodeContent.tag(new Node(ln(0), "b"))
318 		])
319 	]));
320 	assert(parseDiet("a #[b #[c d]]").nodes.nodeEq([
321 		new Node(ln(0), "a", null, [
322 			NodeContent.tag(new Node(ln(0), "b", null, [
323 				NodeContent.tag(new Node(ln(0), "c", null, [
324 					NodeContent.text("d", ln(0))
325 				]))
326 			]))
327 		])
328 	]));
329 
330 	// whitespace fitting
331 	assert(parseDiet("a<>").nodes.nodeEq([
332 		new Node(ln(0), "a", null, [], NodeAttribs.fitInside|NodeAttribs.fitOutside)
333 	]));
334 	assert(parseDiet("a><").nodes.nodeEq([
335 		new Node(ln(0), "a", null, [], NodeAttribs.fitInside|NodeAttribs.fitOutside)
336 	]));
337 	assert(parseDiet("a<").nodes.nodeEq([
338 		new Node(ln(0), "a", null, [], NodeAttribs.fitInside)
339 	]));
340 	assert(parseDiet("a>").nodes.nodeEq([
341 		new Node(ln(0), "a", null, [], NodeAttribs.fitOutside)
342 	]));
343 }
344 
345 @safe unittest {
346 	Location ln(int l) { return Location("string", l); }
347 
348 	// angular2 html attributes tests
349 	assert(parseDiet("div([value]=\"firstName\")").nodes.nodeEq([
350 		new Node(ln(0), "div", [
351 			Attribute(ln(0), "[value]", [
352 				AttributeContent.text("firstName"),
353 			])
354 		])
355 	]));
356 
357 	assert(parseDiet("div([attr.role]=\"myRole\")").nodes.nodeEq([
358 		new Node(ln(0), "div", [
359 			Attribute(ln(0), "[attr.role]", [
360 				AttributeContent.text("myRole"),
361 			])
362 		])
363 	]));
364 
365 	assert(parseDiet("div([attr.role]=\"{foo:myRole}\")").nodes.nodeEq([
366 		new Node(ln(0), "div", [
367 			Attribute(ln(0), "[attr.role]", [
368 				AttributeContent.text("{foo:myRole}"),
369 			])
370 		])
371 	]));
372 
373 	assert(parseDiet("div([attr.role]=\"{foo:myRole, bar:MyRole}\")").nodes.nodeEq([
374 		new Node(ln(0), "div", [
375 			Attribute(ln(0), "[attr.role]", [
376 				AttributeContent.text("{foo:myRole, bar:MyRole}")
377 			])
378 		])
379 	]));
380 
381 	assert(parseDiet("div((attr.role)=\"{foo:myRole, bar:MyRole}\")").nodes.nodeEq([
382 		new Node(ln(0), "div", [
383 			Attribute(ln(0), "(attr.role)", [
384 				AttributeContent.text("{foo:myRole, bar:MyRole}")
385 			])
386 		])
387 	]));
388 
389 	assert(parseDiet("div([class.extra-sparkle]=\"isDelightful\")").nodes.nodeEq([
390 		new Node(ln(0), "div", [
391 			Attribute(ln(0), "[class.extra-sparkle]", [
392 				AttributeContent.text("isDelightful")
393 			])
394 		])
395 	]));
396 
397 	auto t = parseDiet("div((click)=\"readRainbow($event)\")");
398 	assert(t.nodes.nodeEq([
399 		new Node(ln(0), "div", [
400 			Attribute(ln(0), "(click)", [
401 				AttributeContent.text("readRainbow($event)")
402 			])
403 		])
404 	]));
405 
406 	assert(parseDiet("div([(title)]=\"name\")").nodes.nodeEq([
407 		new Node(ln(0), "div", [
408 			Attribute(ln(0), "[(title)]", [
409 				AttributeContent.text("name")
410 			])
411 		])
412 	]));
413 
414 	assert(parseDiet("div(*myUnless=\"myExpression\")").nodes.nodeEq([
415 		new Node(ln(0), "div", [
416 			Attribute(ln(0), "*myUnless", [
417 				AttributeContent.text("myExpression")
418 			])
419 		])
420 	]));
421 
422 	assert(parseDiet("div([ngClass]=\"{active: isActive, disabled: isDisabled}\")").nodes.nodeEq([
423 		new Node(ln(0), "div", [
424 			Attribute(ln(0), "[ngClass]", [
425 				AttributeContent.text("{active: isActive, disabled: isDisabled}")
426 			])
427 		])
428 	]));
429 
430 	t = parseDiet("div(*ngFor=\"\\#item of list\")");
431 	assert(t.nodes.nodeEq([
432 		new Node(ln(0), "div", [
433 			Attribute(ln(0), "*ngFor", [
434 				AttributeContent.text("#"),
435 				AttributeContent.text("item of list")
436 			])
437 		])
438 	]));
439 
440 	t = parseDiet("div(({*ngFor})=\"{args:\\#item of list}\")");
441 	assert(t.nodes.nodeEq([
442 		new Node(ln(0), "div", [
443 			Attribute(ln(0), "({*ngFor})", [
444 				AttributeContent.text("{args:"),
445 				AttributeContent.text("#"),
446 				AttributeContent.text("item of list}")
447 			])
448 		])
449 	]));
450 }
451 /*
452 @safe unittest { // translation
453 	import std.string : toUpper;
454 
455 	static Location ln(int l) { return Location("string", l); }
456 
457 	static string tr(string str) { return "("~toUpper(str)~")"; }
458 
459 	assert(parseDiet!tr("foo& test").nodes.nodeEq([
460 		new Node(ln(0), "foo", null, [
461 			NodeContent.text("(TEST)", ln(0))
462 		], NodeAttribs.translated, "test")
463 	]));
464 
465 	assert(parseDiet!tr("foo& test #{x} it").nodes.nodeEq([
466 		new Node(ln(0), "foo", null, [
467 			NodeContent.text("(TEST ", ln(0)),
468 			NodeContent.interpolation("X", ln(0)),
469 			NodeContent.text(" IT)", ln(0)),
470 		], NodeAttribs.translated, "test #{x} it")
471 	]));
472 
473 	assert(parseDiet!tr("|&x").nodes.nodeEq([
474 		new Node(ln(0), Node.SpecialName.text, null, [NodeContent.text("(X)", ln(0))], NodeAttribs.translated, "x")
475 	]));
476 
477 	assert(parseDiet!tr("foo&.\n\tbar\n\tbaz").nodes.nodeEq([
478 		new Node(ln(0), "foo", null, [
479 			NodeContent.text("(BAR)", ln(1)),
480 			NodeContent.text("\n(BAZ)", ln(2))
481 		], NodeAttribs.translated|NodeAttribs.textNode, "bar\nbaz")
482 	]));
483 }
484 
485 @safe unittest { // test expected errors
486 	void testFail(string diet, string msg)
487 	{
488 		try {
489 			parseDiet(diet);
490 			assert(false, "Expected exception was not thrown.");
491 		} catch (DietParserException ex) assert(ex.msg == msg, "Unexpected error message: "~ex.msg);
492 	}
493 
494 	testFail("+test", "Expected node text separated by a space character or end of line, but got '+test'.");
495 	testFail("  test", "First node must not be indented.");
496 	testFail("test\n  test\n\ttest", "Mismatched indentation style.");
497 	testFail("test\n\ttest\n\t\t\ttest", "Line is indented too deeply.");
498 	testFail("test#", "Expected identifier but got nothing.");
499 	testFail("test.()", "Expected identifier but got '('.");
500 	testFail("a #[b.]", "Multi-line text nodes are not permitted for inline-tags.");
501 	testFail("a #[b: c]", "Nested inline-tags not allowed.");
502 	testFail("a#foo#bar", "Only one \"id\" definition using '#' is allowed.");
503 }
504 
505 @safe unittest { // includes
506 	Node[] parse(string diet) {
507 		auto files = [
508 			InputFile("main.dt", diet),
509 			InputFile("inc.dt", "p")
510 		];
511 		return parseDiet(files).nodes;
512 	}
513 
514 	void testFail(string diet, string msg)
515 	{
516 		try {
517 			parse(diet);
518 			assert(false, "Expected exception was not thrown");
519 		} catch (DietParserException ex) {
520 			assert(ex.msg == msg, "Unexpected error message: "~ex.msg);
521 		}
522 	}
523 
524 	assert(parse("include inc").nodeEq([
525 		new Node(Location("inc.dt", 0), "p", null, null)
526 	]));
527 	testFail("include main", "Dependency cycle detected for this module.");
528 	testFail("include inc2", "Missing include input file: inc2 for main.dt");
529 	testFail("include #{p}", "Dynamic includes are not supported.");
530 	testFail("include inc\n\tp", "Only 'block' allowed as children of includes.");
531 	testFail("p\ninclude inc\n\tp", "Only 'block' allowed as children of includes.");
532 }
533 
534 @safe unittest { // extensions
535 	Node[] parse(string diet) {
536 		auto files = [
537 			InputFile("main.dt", diet),
538 			InputFile("root.dt", "html\n\tblock a\n\tblock b"),
539 			InputFile("intermediate.dt", "extends root\nblock a\n\tp"),
540 			InputFile("direct.dt", "block a")
541 		];
542 		return parseDiet(files).nodes;
543 	}
544 
545 	void testFail(string diet, string msg)
546 	{
547 		try {
548 			parse(diet);
549 			assert(false, "Expected exception was not thrown");
550 		} catch (DietParserException ex) {
551 			assert(ex.msg == msg, "Unexpected error message: "~ex.msg);
552 		}
553 	}
554 
555 	assert(parse("extends root").nodeEq([
556 		new Node(Location("root.dt", 0), "html", null, null)
557 	]));
558 	assert(parse("extends root\nblock a\n\tdiv\nblock b\n\tpre").nodeEq([
559 		new Node(Location("root.dt", 0), "html", null, [
560 			NodeContent.tag(new Node(Location("main.dt", 2), "div", null, null)),
561 			NodeContent.tag(new Node(Location("main.dt", 4), "pre", null, null))
562 		])
563 	]));
564 	assert(parse("extends intermediate\nblock b\n\tpre").nodeEq([
565 		new Node(Location("root.dt", 0), "html", null, [
566 			NodeContent.tag(new Node(Location("intermediate.dt", 2), "p", null, null)),
567 			NodeContent.tag(new Node(Location("main.dt", 2), "pre", null, null))
568 		])
569 	]));
570 	assert(parse("extends intermediate\nblock a\n\tpre").nodeEq([
571 		new Node(Location("root.dt", 0), "html", null, [
572 			NodeContent.tag(new Node(Location("main.dt", 2), "pre", null, null))
573 		])
574 	]));
575 	assert(parse("extends intermediate\nappend a\n\tpre").nodeEq([
576 		new Node(Location("root.dt", 0), "html", null, [
577 			NodeContent.tag(new Node(Location("intermediate.dt", 2), "p", null, null)),
578 			NodeContent.tag(new Node(Location("main.dt", 2), "pre", null, null))
579 		])
580 	]));
581 	assert(parse("extends intermediate\nprepend a\n\tpre").nodeEq([
582 		new Node(Location("root.dt", 0), "html", null, [
583 			NodeContent.tag(new Node(Location("main.dt", 2), "pre", null, null)),
584 			NodeContent.tag(new Node(Location("intermediate.dt", 2), "p", null, null))
585 		])
586 	]));
587 	assert(parse("extends intermediate\nprepend a\n\tfoo\nappend a\n\tbar").nodeEq([ // issue #13
588 		new Node(Location("root.dt", 0), "html", null, [
589 			NodeContent.tag(new Node(Location("main.dt", 2), "foo", null, null)),
590 			NodeContent.tag(new Node(Location("intermediate.dt", 2), "p", null, null)),
591 			NodeContent.tag(new Node(Location("main.dt", 4), "bar", null, null))
592 		])
593 	]));
594 	assert(parse("extends intermediate\nprepend a\n\tfoo\nprepend a\n\tbar\nappend a\n\tbaz\nappend a\n\tbam").nodeEq([
595 		new Node(Location("root.dt", 0), "html", null, [
596 			NodeContent.tag(new Node(Location("main.dt", 2), "foo", null, null)),
597 			NodeContent.tag(new Node(Location("main.dt", 4), "bar", null, null)),
598 			NodeContent.tag(new Node(Location("intermediate.dt", 2), "p", null, null)),
599 			NodeContent.tag(new Node(Location("main.dt", 6), "baz", null, null)),
600 			NodeContent.tag(new Node(Location("main.dt", 8), "bam", null, null))
601 		])
602 	]));
603 	assert(parse("extends direct").nodeEq([]));
604 	assert(parse("extends direct\nblock a\n\tp").nodeEq([
605 		new Node(Location("main.dt", 2), "p", null, null)
606 	]));
607 }
608 
609 @safe unittest { // include extensions
610 	Node[] parse(string diet) {
611 		auto files = [
612 			InputFile("main.dt", diet),
613 			InputFile("root.dt", "p\n\tblock a"),
614 		];
615 		return parseDiet(files).nodes;
616 	}
617 
618 	assert(parse("body\n\tinclude root\n\t\tblock a\n\t\t\tem").nodeEq([
619 		new Node(Location("main.dt", 0), "body", null, [
620 			NodeContent.tag(new Node(Location("root.dt", 0), "p", null, [
621 				NodeContent.tag(new Node(Location("main.dt", 3), "em", null, null))
622 			]))
623 		])
624 	]));
625 
626 	assert(parse("body\n\tinclude root\n\t\tblock a\n\t\t\tem\n\tinclude root\n\t\tblock a\n\t\t\tstrong").nodeEq([
627 		new Node(Location("main.dt", 0), "body", null, [
628 			NodeContent.tag(new Node(Location("root.dt", 0), "p", null, [
629 				NodeContent.tag(new Node(Location("main.dt", 3), "em", null, null))
630 			])),
631 			NodeContent.tag(new Node(Location("root.dt", 0), "p", null, [
632 				NodeContent.tag(new Node(Location("main.dt", 6), "strong", null, null))
633 			]))
634 		])
635 	]));
636 }
637 
638 
639 @safe unittest { // test CTFE-ability
640 	static const result = parseDiet("foo#id.cls(att=\"val\", att2=1+3, att3='test#{4}it')\n\tbar");
641 	static assert(result.nodes.length == 1);
642 }
643 
644 @safe unittest { // regression tests
645 	Location ln(int l) { return Location("string", l); }
646 
647 	// last line contains only whitespace
648 	assert(parseDiet("test\n\t").nodes.nodeEq([
649 		new Node(ln(0), "test")
650 	]));
651 }
652 
653 @safe unittest { // issue #14 - blocks in includes
654 	auto files = [
655 		InputFile("main.dt", "extends layout\nblock nav\n\tbaz"),
656 		InputFile("layout.dt", "foo\ninclude inc"),
657 		InputFile("inc.dt", "bar\nblock nav"),
658 	];
659 	assert(parseDiet(files).nodes.nodeEq([
660 		new Node(Location("layout.dt", 0), "foo", null, null),
661 		new Node(Location("inc.dt", 0), "bar", null, null),
662 		new Node(Location("main.dt", 2), "baz", null, null)
663 	]));
664 }
665 
666 @safe unittest { // issue #32 - numeric id/class
667 	Location ln(int l) { return Location("string", l); }
668 	assert(parseDiet("foo.01#02").nodes.nodeEq([
669 		new Node(ln(0), "foo", [
670 			Attribute(ln(0), "class", [AttributeContent.text("01")]),
671 			Attribute(ln(0), "id", [AttributeContent.text("02")])
672 		])
673 	]));
674 }
675 */
676 
677 /** Dummy translation function that returns the input unmodified.
678 */
679 string identity(string str, string context = null) nothrow @safe @nogc { return str; }
680 
681 
682 private string parseIdent(in string str, ref size_t start,
683 	   	string breakChars, in Location loc)
684 @safe {
685 	/* The stack is used to keep track of opening and
686 	closing character pairs, so that when we hit a break char of
687 	breakChars we know if we can actually break parseIdent.
688 	*/
689 	Vector!char stack;
690 	size_t i = start;
691 	outer: while(i < str.length) {
692 		if(stack.length == 0) {
693 			foreach(char it; breakChars) {
694 				if(str[i] == it) {
695 					break outer;
696 				}
697 			}
698 		}
699 
700 		if(stack.length && stack[$-1] == str[i]) {
701 			stack[] = stack[0 .. $ - 1];
702 		} else if(str[i] == '"') {
703 			stack ~= '"';
704 		} else if(str[i] == '(') {
705 			stack ~= ')';
706 		} else if(str[i] == '[') {
707 			stack ~= ']';
708 		} else if(str[i] == '{') {
709 			stack ~= '}';
710 		}
711 		++i;
712 	}
713 
714 	/* We could have consumed the complete string and still have elements
715 	on the stack or have ended non breakChars character.
716 	*/
717 	if(i < str.length && stack.length == 0) {
718 		foreach(char it; breakChars) {
719 			if(str[i] == it) {
720 				size_t startC = start;
721 				start = i;
722 				return str[startC .. i];
723 			}
724 		}
725 	}
726 	enforcep(false, format!"Identifier was not ended by any of these characters: %s"(breakChars), loc);
727 	assert(false);
728 }
729 /*
730 @safe unittest { // issue #75
731 	string foo = "(failure";
732 	Location loc;
733 	size_t pos = 1;
734 	import std.exception : assertThrown;
735 	assertThrown!(DietParserException)(parseIdent(foo, pos, ")", loc));
736 }
737 */
738 private Array!Node parseDietWithExtensions(FileInfo[] files, size_t file_index, ref Vector!BlockInfo blocks, size_t[] import_stack)
739 @safe {
740 
741 	auto floc = Location(Array!char(files[file_index].name[]), 0);
742 	enforcep(!import_stack.ctcanFind(file_index), "Dependency cycle detected for this module.", floc);
743 
744 	Array!Node nodes = Array!Node(files[file_index].nodes[]);
745 	if (nodes.length == 0) return Array!Node();
746 
747 	if (nodes[0].name[] == "extends") {
748 		// extract base template name/index
749 		enforcep(nodes[0].isTextNode, "'extends' cannot contain children or interpolations.", nodes[0].loc);
750 		enforcep(nodes[0].attributes.length == 0, "'extends' cannot have attributes.", nodes[0].loc);
751 
752 		string base_template = nodes[0].contents[0].value[].ctstrip;
753 		size_t base_idx;
754 		foreach(i, ref f; files) {
755 			if (matchesName(f.name[], base_template, files[file_index].name[]))
756 			{
757 				base_idx = i;
758 				break;
759 			}
760 		}
761 
762 		assert(base_idx >= 0, format!"Missing base template: %s"(base_template));
763 
764 		// collect all blocks
765 		foreach (n; nodes[1 .. $]) {
766 			BlockInfo.Mode mode;
767 			switch (n.name[]) {
768 				default:
769 					enforcep(false, "Extension templates may only contain blocks definitions at the root level.", n.loc);
770 					break;
771 				case Node.SpecialName.comment, Node.SpecialName.hidden: continue; // also allow comments at the root level
772 				case "block": mode = BlockInfo.Mode.replace; break;
773 				case "prepend": mode = BlockInfo.Mode.prepend; break;
774 				case "append": mode = BlockInfo.Mode.append; break;
775 			}
776 			enforcep(n.contents.length > 0 && n.contents[0].kind == NodeContent.Kind.text,
777 				"'block' must have a name.", n.loc);
778 			auto name = n.contents[0].value[].ctstrip;
779 			Array!Node contents;
780 			foreach (c; n.contents[]) {
781 				if (c.kind == NodeContent.Kind.node)
782 					contents ~= c.node;
783 			}
784 			blocks ~= BlockInfo(Array!char(name), mode, contents);
785 		}
786 
787 		// save the original file contents for a possible later parsing as part of an
788 		// extension include directive (blocks are replaced in-place as part of the parsing
789 		// process)
790 		Vector!FileInfo new_files;
791 		foreach (ref file; files) {
792 			auto file_info = FileInfo(Array!char(file.name[]), Array!Node(file.nodes[]));
793 			//foreach(ref node; file.nodes[]) {
794 			//	file_info.nodes ~= node.clone();
795 			//}
796 			new_files ~= file_info;
797 		}
798 		new_files[base_idx].nodes[] = clone(new_files[base_idx].nodes[]);
799 
800 		// parse base template
801 		Vector!size_t new_import_stack = Vector!size_t(import_stack);
802 		new_import_stack ~= file_index;
803 		return parseDietWithExtensions(new_files[], base_idx, blocks, new_import_stack[]);
804 	}
805 
806 	static string extractFilename(Node n) @safe
807 	{
808 		enforcep(n.contents.length >= 1 && n.contents[0].kind != NodeContent.Kind.node,
809 			"Missing block name.", n.loc);
810 		enforcep(n.contents[0].kind == NodeContent.Kind.text,
811 			"Dynamic includes are not supported.", n.loc);
812 		bool all_nodes = true;
813 		if (n.contents.length > 1) foreach (nc; n.contents[1 .. $]) {
814 			if (nc.kind != NodeContent.Kind.node) {
815 				all_nodes = false;
816 				break;
817 			}
818 		}
819 		enforcep(n.contents.length == 1 || all_nodes,
820 			format!"'%s' must only contain a block name and child nodes."(n.name[]), n.loc);
821 		enforcep(n.attributes.length == 0, format!"'%s' cannot have attributes."(n.name[]), n.loc);
822 		return n.contents[0].value[].ctstrip;
823 	}
824 
825 	Vector!Node processNode(Node n) @safe nothrow {
826 		Vector!Node ret;
827 
828 		void insert(Node[] nodes) @safe nothrow {
829 			foreach (i, n; nodes) {
830 				auto np = processNode(n);
831 				if (np.length > 0) {
832 					if (ret.length == 0) ret[] = nodes[0 .. i];
833 					ret ~= np;
834 				} else if (ret.length > 0) ret ~= n;
835 			}
836 			if (ret.length == 0 && nodes.length > 0) ret[] = nodes;
837 		}
838 
839 		if (n.name[] == "block") {
840 			auto name = extractFilename(n);
841 			Vector!BlockInfo blockdefs;
842 			foreach (b; blocks) {
843 				if (b.name[] == name[])
844 					blockdefs ~= b;
845 
846 			}
847 
848 			foreach (b; blockdefs[]) {
849 				if (b.mode == BlockInfo.Mode.prepend)
850 					insert(b.contents[]);
851 			}
852 
853 			Vector!BlockInfo replblocks;
854 			foreach(b; blockdefs[]) {
855 				if (b.mode == BlockInfo.Mode.replace)
856 					replblocks ~= b;
857 			}
858 			if (!replblocks.empty) {
859 				insert(replblocks.front.contents[]);
860 			} else {
861 				Vector!Node contents;
862 
863 				foreach (nc; n.contents[1 .. $]) {
864 					assert(nc.kind == NodeContent.Kind.node, "Block contains non-node child!?");
865 					contents ~= nc.node;
866 				}
867 				insert(contents[]);
868 			}
869 
870 			foreach (b; blockdefs[]) {
871 				if (b.mode == BlockInfo.Mode.append)
872 					insert(b.contents[]);
873 			}
874 		} else if (n.name[] == "include") {
875 			auto name = extractFilename(n);
876 			size_t fidx;
877 			foreach(i, ref f; files) {
878 				if (matchesName(f.name[], name[], n.loc.file[]))
879 				{
880 					fidx = i;
881 					break;
882 				}
883 			}
884 			enforcep(fidx >= 0, format!"Missing include input file: %s for %s"(name[], n.loc.file[]), n.loc);
885 
886 			if (n.contents.length > 1) {
887 				auto dummy = Node(n.loc, Array!char("extends"));
888 				dummy.addText(name[], n.contents[0].loc);
889 
890 				Array!Node children;
891 				children ~= dummy;
892 
893 				foreach (nc; n.contents[1 .. $]) {
894 					enforcep(nc.node.name[].length > 0 && nc.node.name[] == "block",
895 						"Only 'block' allowed as children of includes.", nc.loc);
896 					children ~= nc.node;
897 				}
898 
899 				auto dummyfil = FileInfo(Array!char(format!"include%s"(ctExtension(files[file_index].name[]))), children);
900 
901 				Vector!BlockInfo sub_blocks;
902 				Vector!FileInfo concat_vec = Vector!FileInfo(files);
903 				concat_vec ~= dummyfil;
904 				auto sub_nodes = parseDietWithExtensions(concat_vec[], files.length, sub_blocks, import_stack);
905 				insert(sub_nodes[]);
906 			} else {
907 				Vector!size_t new_import_stack = Vector!size_t(import_stack);
908 				new_import_stack ~= file_index;
909 				auto sub_nodes = parseDietWithExtensions(files, fidx, blocks, new_import_stack[]);
910 				insert(sub_nodes[]);
911 			}
912 		} else {
913 			n.contents[] = n.contents[].mapJoin!((nc) nothrow {
914 				Vector!(NodeContent*) rn_arr;
915 				if (nc.kind == NodeContent.Kind.node) {
916 					auto mod = processNode(nc.node);
917 					if (mod.length > 0) {
918 						foreach(ref n; mod[])
919 							rn_arr ~= NodeContent.tag(n);
920 					}
921 				}
922 				bool _all_non_blocks = true;
923 				foreach (ref n; rn_arr[]) {
924 					if (n.node.name[] == "block")
925 					{
926 						_all_non_blocks = false;
927 					}
928 				}
929 				assert(_all_non_blocks);
930 				return rn_arr.move();
931 			});
932 		}
933 
934 		bool all_non_blocks = true;
935 		foreach (ref nn; ret[]) {
936 			if (nn.name[] == "block")
937 			{
938 				all_non_blocks = false;
939 			}
940 		}
941 		assert(all_non_blocks);
942 
943 		return ret.move();
944 	}
945 
946 	nodes[] = nodes[].mapJoin!(processNode);
947 
948 
949 	bool all_non_blocks = true;
950 	foreach (ref n; nodes[]) {
951 		if (n.name[] == "block")
952 		{
953 			all_non_blocks = false;
954 		}
955 	}
956 	assert(all_non_blocks);
957 
958 	return nodes;
959 }
960 
961 private struct BlockInfo {
962 	enum Mode {
963 		prepend,
964 		replace,
965 		append
966 	}
967 	Array!char name;
968 	Mode mode = Mode.replace;
969 	Array!Node contents;
970 }
971 
972 private struct FileInfo {
973 	Array!char name;
974 	Array!Node nodes;
975 }
976 
977 
978 /** Parses a single Diet template file, without resolving includes and extensions.
979 
980 	See_Also: `parseDiet`
981 */
982 Node[] parseDietRaw(alias TR)(immutable(InputFile) file)
983 {
984 
985 	string indent_style;
986 	auto loc = Location(Array!char(file.name[]), 0);
987 	int prevlevel = -1;
988 	string input = file.contents[];
989 	Array!Node ret;
990 	// nested stack of nodes
991 	// the first dimension is corresponds to indentation based nesting
992 	// the second dimension is for in-line nested nodes
993 	Array!(Array!Node) stack;
994 	stack.length = 8;
995 	string previndent; // inherited by blank lines
996 
997 	next_line:
998 	while (input.length) {
999 		Node pnode;
1000 		if (prevlevel >= 0 && stack[prevlevel].length) pnode = stack[prevlevel][$-1];
1001 
1002 		// skip whitespace at the beginning of the line
1003 		string indent = input.skipIndent();
1004 
1005 		// treat empty lines as if they had the indendation level of the last non-empty line
1006 		if (input.length == 0 || input[0] == '\n' || input[0] == '\r')
1007 			indent = previndent;
1008 		else previndent = indent;
1009 
1010 		enforcep(prevlevel >= 0 || indent.length == 0, "First node must not be indented.", loc);
1011 
1012 		// determine the indentation style (tabs/spaces) from the first indented
1013 		// line of the file
1014 		if (indent.length && !indent_style.length) indent_style = indent;
1015 
1016 		// determine nesting level
1017 		bool is_text_line = pnode.name[].length > 0 && (pnode.attribs & (NodeAttribs.textNode|NodeAttribs.rawTextNode)) != 0;
1018 		int level = 0;
1019 		if (indent_style.length) {
1020 			while (indent.ctstartsWith(indent_style)) {
1021 				if (level > prevlevel) {
1022 					enforcep(is_text_line, "Line is indented too deeply.", loc);
1023 					break;
1024 				}
1025 				level++;
1026 				indent = indent[indent_style.length .. $];
1027 			}
1028 		}
1029 
1030 		enforcep(is_text_line || indent.length == 0, "Mismatched indentation style.", loc);
1031 
1032 		// read the whole line as text if the parent node is a pure text node
1033 		// ("." suffix) or pure raw text node (e.g. comments)
1034 		if (level > prevlevel && prevlevel >= 0) {
1035 			if (pnode.attribs & NodeAttribs.textNode) {
1036 				if (!pnode.contents.empty) {
1037 					pnode.addText("\n", loc);
1038 					if (pnode.attribs & NodeAttribs.translated)
1039 						pnode.translationKey ~= "\n";
1040 				}
1041 				if (indent.length) pnode.addText(indent, loc);
1042 				parseTextLine!TR(input, pnode, loc);
1043 				continue;
1044 			} else if (pnode.attribs & NodeAttribs.rawTextNode) {
1045 				if (!pnode.contents.empty)
1046 					pnode.addText("\n", loc);
1047 				if (indent.length) pnode.addText(indent, loc);
1048 				auto tmploc = loc;
1049 				pnode.addText(skipLine(input, loc), tmploc);
1050 				continue;
1051 			}
1052 		}
1053 
1054 		// skip empty lines
1055 		if (input.length == 0) break;
1056 		else if (input[0] == '\n') { loc.line++; input = input[1 .. $]; continue; }
1057 		else if (input[0] == '\r') {
1058 			loc.line++;
1059 			input = input[1 .. $];
1060 			if (input.length != 0 && input[0] == '\n')
1061 				input = input[1 .. $];
1062 			continue;
1063 		}
1064 
1065 		// parse the line and write it to the stack:
1066 
1067 		if (stack.length < level+1) stack.length = level+1;
1068 
1069 		if (input.ctstartsWith("//")) {
1070 			// comments
1071 			auto n = Node.init;
1072 			n.loc = loc;
1073 			if (input[2 .. $].ctstartsWith("-")) { n.name[] = cast(string)Node.SpecialName.hidden; input = input[3 .. $]; }
1074 			else { n.name[] = cast(string)Node.SpecialName.comment; input = input[2 .. $]; }
1075 			n.attribs |= NodeAttribs.rawTextNode;
1076 			auto tmploc = loc;
1077 			n.addText(skipLine(input, loc), tmploc);
1078 			stack[level].clear();
1079 			stack[level] ~= n;
1080 		} else if (input.ctstartsWith('-')) {
1081 			// D statements
1082 			input = input[1 .. $];
1083 			auto n = Node.init;
1084 			n.loc = loc;
1085 			n.name[] = cast(string)Node.SpecialName.code;
1086 			auto tmploc = loc;
1087 			n.addText(skipLine(input, loc), tmploc);
1088 			stack[level].clear();
1089 			stack[level] ~= n;
1090 		} else if (input.ctstartsWith(':')) {
1091 			// filters
1092 			stack[level].clear();
1093 
1094 
1095 			Vector!char chain;
1096 
1097 			do {
1098 				input = input[1 .. $];
1099 				size_t idx = 0;
1100 				if (chain.length) chain ~= ' ';
1101 				chain ~= skipIdent(input, idx, "-_", loc, false, true);
1102 				input = input[idx .. $];
1103 				if (input.ctstartsWith(' ')) input = input[1 .. $];
1104 			} while (input.ctstartsWith(':'));
1105 
1106 			Node chn = Node.init;
1107 			chn.loc = loc;
1108 			chn.name[] = cast(string)Node.SpecialName.filter;
1109 			chn.attribs = NodeAttribs.textNode;
1110 			chn.attributes.clear();
1111 			chn.attributes ~= Attribute(loc, "filterChain", AttributeContent.text(chain[]));
1112 			stack[level] ~= chn;
1113 
1114 			/*auto tmploc = loc;
1115 			auto trailing = skipLine(input, loc);
1116 			if (trailing.length) parseTextLine(input, chn, tmploc);*/
1117 			parseTextLine!TR(input, chn, loc);
1118 		} else {
1119 			// normal tag line
1120 			bool has_nested;
1121 			stack[level].clear();
1122 			do stack[level] ~= parseTagLine!TR(input, loc, has_nested);
1123 			while (has_nested);
1124 		}
1125 
1126 		// add it to its parent contents
1127 		foreach (i; 1 .. stack[level].length)
1128 			stack[level][i-1].contents ~= NodeContent.tag(stack[level][i]);
1129 		if (level > 0) stack[level-1][$-1].contents ~= NodeContent.tag(stack[level][0]);
1130 		else ret ~= stack[0][0];
1131 
1132 		// remember the nesting level for the next line
1133 		prevlevel = level;
1134 	}
1135 
1136 	return ret[].copy();
1137 }
1138 
1139 private Node parseTagLine(alias TR)(ref string input, ref Location loc, out bool has_nested) @trusted
1140 {
1141 	size_t idx = 0;
1142 
1143 	auto ret = Node.init;
1144 	ret.loc = loc;
1145 
1146 	if (input.ctstartsWith("!!! ")) { // legacy doctype support
1147 		input = input[4 .. $];
1148 		ret.name[] = "doctype";
1149 		parseTextLine!TR(input, ret, loc);
1150 		return ret;
1151 	}
1152 
1153 	if (input.ctstartsWith('<')) { // inline HTML/XML
1154 		ret.name[] = cast(string)Node.SpecialName.text;
1155 		parseTextLine!TR(input, ret, loc);
1156 		return ret;
1157 	}
1158 
1159 	if (input.ctstartsWith('|')) { // text line
1160 		input = input[1 .. $];
1161 		ret.name[] = cast(string)Node.SpecialName.text;
1162 		if (idx < input.length && input[idx] == '&') { ret.attribs |= NodeAttribs.translated; idx++; }
1163 	} else { // normal tag
1164 		if (parseTag(input, idx, ret, has_nested, loc))
1165 			return ret;
1166 	}
1167 
1168 	if (idx+1 < input.length && input[idx .. idx+2] == "!=") {
1169 		enforcep(!(ret.attribs & NodeAttribs.translated), "Compile-time translation is not supported for (raw) assignments.", ret.loc);
1170 		idx += 2;
1171 		auto l = loc;
1172 		ret.contents ~= NodeContent.rawInterpolation(ctstrip(skipLine(input, idx, loc)), l);
1173 		input = input[idx .. $];
1174 	} else if (idx < input.length && input[idx] == '=') {
1175 		enforcep(!(ret.attribs & NodeAttribs.translated), "Compile-time translation is not supported for assignments.", ret.loc);
1176 		idx++;
1177 		auto l = loc;
1178 		ret.contents ~= NodeContent.interpolation(ctstrip(skipLine(input, idx, loc)), l);
1179 		input = input[idx .. $];
1180 	} else {
1181 		auto tmploc = loc;
1182 		auto remainder = skipLine(input, idx, loc);
1183 		input = input[idx .. $];
1184 
1185 		if (remainder.length && remainder[0] == ' ') {
1186 			// parse the rest of the line as text contents (if any non-ws)
1187 			remainder = remainder[1 .. $];
1188 			parseTextLine!TR(remainder, ret, tmploc);
1189 		} else if (ret.name[] == Node.SpecialName.text) {
1190 			// allow omitting the whitespace for "|" text nodes
1191 			parseTextLine!TR(remainder, ret, tmploc);
1192 		} else {
1193 			enforcep(remainder.ctstrip().length == 0,
1194 				format!"Expected node text separated by a space character or end of line, but got '%d'."(remainder), loc);
1195 		}
1196 	}
1197 
1198 	return ret;
1199 }
1200 
1201 private bool parseTag(ref string input, ref size_t idx, ref Node dst, ref bool has_nested, ref Location loc)
1202 @safe {
1203 
1204 	dst.name[] = skipIdent(input, idx, ":-_", loc, true);
1205 
1206 	// a trailing ':' is not part of the tag name, but signals a nested node
1207 	if (dst.name[].ctendsWith(":")) {
1208 		dst.name.removeBack();
1209 		idx--;
1210 	}
1211 
1212 	bool have_id = false;
1213 	while (idx < input.length) {
1214 		if (input[idx] == '#') {
1215 		 	// node ID
1216 			idx++;
1217 			auto value = skipIdent(input, idx, "-_", loc);
1218 			enforcep(value.length > 0, "Expected id.", loc);
1219 			enforcep(!have_id, "Only one \"id\" definition using '#' is allowed.", loc);
1220 			have_id = true;
1221 			dst.attributes ~= Attribute.text("id", value, loc);
1222 		} else if (input[idx] == '.') {
1223 			// node classes
1224 			if (idx+1 >= input.length || input[idx+1].isWhite)
1225 				goto textBlock;
1226 			idx++;
1227 			auto value = skipIdent(input, idx, "-_", loc);
1228 			enforcep(value.length > 0, "Expected class name identifier.", loc);
1229 			dst.attributes ~= Attribute.text("class", value, loc);
1230 		} else break;
1231 	}
1232 
1233 	// generic attributes
1234 	if (idx < input.length && input[idx] == '(')
1235 		parseAttributes(input, idx, dst, loc);
1236 
1237 	// avoid whitespace inside of tag
1238 	if (idx < input.length && input[idx] == '<') {
1239 		idx++;
1240 		dst.attribs |= NodeAttribs.fitInside;
1241 	}
1242 
1243 	// avoid whitespace outside of tag
1244 	if (idx < input.length && input[idx] == '>') {
1245 		idx++;
1246 		dst.attribs |= NodeAttribs.fitOutside;
1247 	}
1248 
1249 	// avoid whitespace inside of tag (also allowed after >)
1250 	if (!(dst.attribs & NodeAttribs.fitInside) && idx < input.length && input[idx] == '<') {
1251 		idx++;
1252 		dst.attribs |= NodeAttribs.fitInside;
1253 	}
1254 
1255 	// translate text contents
1256 	if (idx < input.length && input[idx] == '&') {
1257 		idx++;
1258 		dst.attribs |= NodeAttribs.translated;
1259 	}
1260 
1261 	// treat nested lines as text
1262 	if (idx < input.length && input[idx] == '.') {
1263 		textBlock:
1264 		dst.attribs |= NodeAttribs.textNode;
1265 		idx++;
1266 		skipLine(input, idx, loc); // ignore the rest of the line
1267 		input = input[idx .. $];
1268 		return true;
1269 	}
1270 
1271 	// another nested tag on the same line
1272 	if (idx < input.length && input[idx] == ':') {
1273 		idx++;
1274 
1275 		// skip trailing whitespace (but no line breaks)
1276 		while (idx < input.length && (input[idx] == ' ' || input[idx] == '\t'))
1277 			idx++;
1278 
1279 		// see if we got anything left on the line
1280 		if (idx < input.length) {
1281 			if (input[idx] == '\n' || input[idx] == '\r') {
1282 				// FIXME: should we rather error out here?
1283 				skipLine(input, idx, loc);
1284 			} else {
1285 				// leaves the rest of the line to parse another tag
1286 				has_nested = true;
1287 			}
1288 		}
1289 		input = input[idx .. $];
1290 		return true;
1291 	}
1292 
1293 	return false;
1294 }
1295 
1296 bool isDirSeparator(char c)  @safe pure nothrow @nogc
1297 {
1298     if (c == '/') return true;
1299     if (c == '\\') return true;
1300     return false;
1301 }
1302 
1303 string basenameWithoutExtension(string path) {
1304 	size_t begin;
1305 	size_t end;
1306 	foreach(i, c; path) {
1307 		if (isDirSeparator(c)) {
1308 			begin = i + 1;
1309 		}
1310 		if (c == '.') {
1311 			end = i;
1312 		}
1313 	}
1314 	return path[begin .. end];
1315 }
1316 
1317 /**
1318 	Parses a single line of text (possibly containing interpolations and inline tags).
1319 
1320 	If there a a newline at the end, it will be appended to the contents of the
1321 	destination node.
1322 */
1323 private void parseTextLine(alias TR, bool translate = true)(ref string input, ref Node dst, ref Location loc)
1324 {
1325 
1326 	size_t idx = 0;
1327 
1328 	if (translate && dst.attribs & NodeAttribs.translated) {
1329 		Location loccopy = loc;
1330 		auto kln = skipLine(input, idx, loc);
1331 		input = input[idx .. $];
1332 		dst.translationKey ~= kln;
1333 		static if (is(typeof(TR(string.init, string.init))))
1334 			auto tln = TR(kln, loc.file[].basenameWithoutExtension);
1335 		else
1336 			auto tln = TR(kln);
1337 		parseTextLineRaw(tln, dst, loccopy);
1338 		return;
1339 	}
1340 
1341 	parseTextLineRaw(input, dst, loc);
1342 }
1343 
1344 private void parseTextLineRaw(ref string input, ref Node dst, ref Location loc)
1345 @safe {
1346 
1347 	size_t sidx = 0, idx = 0;
1348 
1349 	void flushText()
1350 	@safe {
1351 		if (idx > sidx) dst.addText(input[sidx .. idx], loc);
1352 	}
1353 
1354 	while (idx < input.length) {
1355 		char cur = input[idx];
1356 		switch (cur) {
1357 			default: idx++; break;
1358 			case '\\':
1359 				if (idx+1 < input.length && (input[idx+1] == '#' || input[idx+1] == '!')) {
1360 					flushText();
1361 					sidx = idx+1;
1362 					idx += 2;
1363 				} else idx++;
1364 				break;
1365 			case '!', '#':
1366 				if (idx+1 < input.length && input[idx+1] == '{') {
1367 					flushText();
1368 					idx += 2;
1369 					auto expr = skipUntilClosingBrace(input, idx, loc);
1370 					idx++;
1371 					if (cur == '#') dst.contents ~= NodeContent.interpolation(expr, loc);
1372 					else dst.contents ~= NodeContent.rawInterpolation(expr, loc);
1373 					sidx = idx;
1374 				} else if (cur == '#' && idx+1 < input.length && input[idx+1] == '[') {
1375 					flushText();
1376 					idx += 2;
1377 					auto tag = skipUntilClosingBracket(input, idx, loc);
1378 					idx++;
1379 					bool has_nested;
1380 					auto itag = parseTagLine!identity(tag, loc, has_nested);
1381 					enforcep(!(itag.attribs & (NodeAttribs.textNode|NodeAttribs.rawTextNode)),
1382 						"Multi-line text nodes are not permitted for inline-tags.", loc);
1383 					enforcep(!(itag.attribs & NodeAttribs.translated),
1384 						"Inline-tags cannot be translated individually.", loc);
1385 					enforcep(!has_nested, "Nested inline-tags not allowed.", loc);
1386 					dst.contents ~= NodeContent.tag(itag);
1387 					sidx = idx;
1388 				} else idx++;
1389 				break;
1390 			case '\r':
1391 				flushText();
1392 				idx++;
1393 				if (idx < input.length && input[idx] == '\n') idx++;
1394 				input = input[idx .. $];
1395 				loc.line++;
1396 				return;
1397 			case '\n':
1398 				flushText();
1399 				idx++;
1400 				input = input[idx .. $];
1401 				loc.line++;
1402 				return;
1403 		}
1404 	}
1405 
1406 	flushText();
1407 	assert(idx == input.length);
1408 	input = null;
1409 }
1410 
1411 private string skipLine(ref string input, ref size_t idx, ref Location loc)
1412 @safe {
1413 	auto sidx = idx;
1414 
1415 	while (idx < input.length) {
1416 		char cur = input[idx];
1417 		switch (cur) {
1418 			default: idx++; break;
1419 			case '\r':
1420 				auto ret = input[sidx .. idx];
1421 				idx++;
1422 				if (idx < input.length && input[idx] == '\n') idx++;
1423 				loc.line++;
1424 				return ret;
1425 			case '\n':
1426 				auto ret = input[sidx .. idx];
1427 				idx++;
1428 				loc.line++;
1429 				return ret;
1430 		}
1431 	}
1432 
1433 	return input[sidx .. $];
1434 }
1435 
1436 private string skipLine(ref string input, ref Location loc)
1437 @safe {
1438 	size_t idx = 0;
1439 	auto ret = skipLine(input, idx, loc);
1440 	input = input[idx .. $];
1441 	return ret;
1442 }
1443 
1444 private void parseAttributes(ref string input, ref size_t i, ref Node node, in Location loc)
1445 @safe {
1446 	assert(i < input.length && input[i] == '(');
1447 	i++;
1448 
1449 	skipAnyWhitespace(input, i);
1450 	while (i < input.length && input[i] != ')') {
1451 		string name = parseIdent(input, i, ",)=", loc);
1452 		Vector!char value;
1453 		skipAnyWhitespace(input, i);
1454 		if( i < input.length && input[i] == '=' ){
1455 			i++;
1456 			skipAnyWhitespace(input, i);
1457 			enforcep(i < input.length, "'=' must be followed by attribute string.", loc);
1458 			value[] = skipExpression(input, i, loc, true);
1459 			assert(i <= input.length);
1460 			if (isStringLiteral(value[]) && value[0] == '\'') {
1461 				value.clear();
1462 				value ~= `"`;
1463 				auto tmp = dstringUnescape(value[1 .. $-1]);
1464 				value ~= dstringEscape(tmp);
1465 				value ~= `"`;
1466 			}
1467 		} else value[] = "true";
1468 
1469 		enforcep(i < input.length, "Unterminated attribute section.", loc);
1470 		enforcep(input[i] == ')' || input[i] == ',',
1471 			format!"Unexpected text following attribute: '%s' ('%s')"(input[0..i], input[i..$]), loc);
1472 		if (input[i] == ',') {
1473 			i++;
1474 			skipAnyWhitespace(input, i);
1475 		}
1476 
1477 		if (name == "class" && value[] == `""`) continue;
1478 
1479 		if (isStringLiteral(value[])) {
1480 			Vector!AttributeContent content;
1481 			parseAttributeText(value[1 .. $-1], content, loc);
1482 			node.attributes ~= Attribute(loc, name, content[]);
1483 		} else {
1484 			node.attributes ~= Attribute.expr(name, value[], loc);
1485 		}
1486 	}
1487 
1488 	enforcep(i < input.length, "Missing closing clamp.", loc);
1489 	i++;
1490 }
1491 
1492 private void parseAttributeText(string input, ref Vector!AttributeContent dst, in Location loc)
1493 @safe {
1494 	size_t sidx = 0, idx = 0;
1495 
1496 	void flushText()
1497 	{
1498 		if (idx > sidx) dst ~= AttributeContent.text(input[sidx .. idx]);
1499 	}
1500 
1501 	while (idx < input.length) {
1502 		char cur = input[idx];
1503 		switch (cur) {
1504 			default: idx++; break;
1505 			case '\\':
1506 				flushText();
1507 				dst ~= AttributeContent.text(dstringUnescape(sanitizeEscaping(input[idx .. idx+2])));
1508 				idx += 2;
1509 				sidx = idx;
1510 				break;
1511 			case '!', '#':
1512 				if (idx+1 < input.length && input[idx+1] == '{') {
1513 					flushText();
1514 					idx += 2;
1515 					auto expr = dstringUnescape(skipUntilClosingBrace(input, idx, loc));
1516 					idx++;
1517 					if (cur == '#') dst ~= AttributeContent.interpolation(expr);
1518 					else dst ~= AttributeContent.rawInterpolation(expr);
1519 					sidx = idx;
1520 				} else idx++;
1521 				break;
1522 		}
1523 	}
1524 
1525 	flushText();
1526 	input = input[idx .. $];
1527 }
1528 
1529 private string skipUntilClosingBrace(in string s, ref size_t idx, in Location loc)
1530 @safe {
1531 
1532 	int level = 0;
1533 	auto start = idx;
1534 	while( idx < s.length ){
1535 		if( s[idx] == '{' ) level++;
1536 		else if( s[idx] == '}' ) level--;
1537 		enforcep(s[idx] != '\n' && s[idx] != '\r', "Missing '}' before end of line.", loc);
1538 		if( level < 0 ) return s[start .. idx];
1539 		idx++;
1540 	}
1541 	enforcep(false, "Missing closing brace", loc);
1542 	assert(false);
1543 }
1544 
1545 private string skipUntilClosingBracket(in string s, ref size_t idx, in Location loc)
1546 @safe {
1547 
1548 	int level = 0;
1549 	auto start = idx;
1550 	while( idx < s.length ){
1551 		if( s[idx] == '[' ) level++;
1552 		else if( s[idx] == ']' ) level--;
1553 		enforcep(s[idx] != '\n' && s[idx] != '\r', "Missing ']' before end of line.", loc);
1554 		if( level < 0 ) return s[start .. idx];
1555 		idx++;
1556 	}
1557 	enforcep(false, "Missing closing bracket", loc);
1558 	assert(false);
1559 }
1560 
1561 /++
1562     Params: c = The character to test.
1563     Returns: Whether `c` is an ASCII letter (A .. Z, a .. z).
1564   +/
1565 bool isAlpha(dchar c) @safe pure nothrow @nogc
1566 {
1567     // Optimizer can turn this into a bitmask operation on 64 bit code
1568     return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
1569 }
1570 
1571 private string skipIdent(in string s, ref size_t idx, string additional_chars,
1572 	in Location loc, bool accept_empty = false, bool require_alpha_start = false)
1573 @safe {
1574 
1575 	size_t start = idx;
1576 	while (idx < s.length) {
1577 		if (isAlpha(s[idx])) idx++;
1578 		else if ((!require_alpha_start || start != idx) && s[idx] >= '0' && s[idx] <= '9') idx++;
1579 		else {
1580 			bool found = false;
1581 			foreach (ch; additional_chars)
1582 				if (s[idx] == ch) {
1583 					found = true;
1584 					idx++;
1585 					break;
1586 				}
1587 			if (!found) {
1588 				enforcep(accept_empty || start != idx, format!"Expected identifier but got '%s'."(s[idx]), loc);
1589 				return s[start .. idx];
1590 			}
1591 		}
1592 	}
1593 	enforcep(start != idx, "Expected identifier but got nothing.", loc);
1594 	return s[start .. idx];
1595 }
1596 
1597 /// Skips all trailing spaces and tab characters of the input string.
1598 private string skipIndent(ref string input)
1599 @safe {
1600 	size_t idx = 0;
1601 	while (idx < input.length && isIndentChar(input[idx]))
1602 		idx++;
1603 	auto ret = input[0 .. idx];
1604 	input = input[idx .. $];
1605 	return ret;
1606 }
1607 
1608 private bool isIndentChar(dchar ch) @safe { return ch == ' ' || ch == '\t'; }
1609 
1610 private string skipAnyWhitespace(in string s, ref size_t idx)
1611 @safe {
1612 
1613 	size_t start = idx;
1614 	while (idx < s.length) {
1615 		if (s[idx].isWhite) idx++;
1616 		else break;
1617 	}
1618 	return s[start .. idx];
1619 }
1620 
1621 private bool isStringLiteral(string str)
1622 @safe {
1623 	size_t i = 0;
1624 
1625 	// skip leading white space
1626 	while (i < str.length && (str[i] == ' ' || str[i] == '\t')) i++;
1627 
1628 	// no string literal inside
1629 	if (i >= str.length) return false;
1630 
1631 	char delimiter = str[i++];
1632 	if (delimiter != '"' && delimiter != '\'') return false;
1633 
1634 	while (i < str.length && str[i] != delimiter) {
1635 		if (str[i] == '\\') i++;
1636 		i++;
1637 	}
1638 
1639 	// unterminated string literal
1640 	if (i >= str.length) return false;
1641 
1642 	i++; // skip delimiter
1643 
1644 	// skip trailing white space
1645 	while (i < str.length && (str[i] == ' ' || str[i] == '\t')) i++;
1646 
1647 	// check if the string has ended with the closing delimiter
1648 	return i == str.length;
1649 }
1650 
1651 @safe unittest {
1652 	assert(isStringLiteral(`""`));
1653 	assert(isStringLiteral(`''`));
1654 	assert(isStringLiteral(`"hello"`));
1655 	assert(isStringLiteral(`'hello'`));
1656 	assert(isStringLiteral(` 	"hello"	 `));
1657 	assert(isStringLiteral(` 	'hello'	 `));
1658 	assert(isStringLiteral(`"hel\"lo"`));
1659 	assert(isStringLiteral(`"hel'lo"`));
1660 	assert(isStringLiteral(`'hel\'lo'`));
1661 	assert(isStringLiteral(`'hel"lo'`));
1662 	assert(isStringLiteral(`'#{"address_"~item}'`));
1663 	assert(!isStringLiteral(`"hello\`));
1664 	assert(!isStringLiteral(`"hello\"`));
1665 	assert(!isStringLiteral(`"hello\"`));
1666 	assert(!isStringLiteral(`"hello'`));
1667 	assert(!isStringLiteral(`'hello"`));
1668 	assert(!isStringLiteral(`"hello""world"`));
1669 	assert(!isStringLiteral(`"hello" "world"`));
1670 	assert(!isStringLiteral(`"hello" world`));
1671 	assert(!isStringLiteral(`'hello''world'`));
1672 	assert(!isStringLiteral(`'hello' 'world'`));
1673 	assert(!isStringLiteral(`'hello' world`));
1674 	assert(!isStringLiteral(`"name" value="#{name}"`));
1675 }
1676 
1677 private string skipExpression(in string s, ref size_t idx, in Location loc, bool multiline = false)
1678 @safe {
1679 	Vector!char clamp_stack;
1680 	size_t start = idx;
1681 	outer:
1682 	while (idx < s.length) {
1683 		switch (s[idx]) {
1684 			default: break;
1685 			case '\n', '\r':
1686 				enforcep(multiline, "Unexpected end of line.", loc);
1687 				break;
1688 			case ',':
1689 				if (clamp_stack.length == 0)
1690 					break outer;
1691 				break;
1692 			case '"', '\'':
1693 				idx++;
1694 				skipAttribString(s, idx, s[idx-1], loc);
1695 				break;
1696 			case '(': clamp_stack ~= ')'; break;
1697 			case '[': clamp_stack ~= ']'; break;
1698 			case '{': clamp_stack ~= '}'; break;
1699 			case ')', ']', '}':
1700 				if (s[idx] == ')' && clamp_stack.length == 0)
1701 					break outer;
1702 				enforcep(clamp_stack.length > 0 && clamp_stack[$-1] == s[idx], format!"Unexpected '%s'"(s[idx]), loc);
1703 				clamp_stack.removeBack();
1704 				break;
1705 		}
1706 		idx++;
1707 	}
1708 
1709 	enforcep(clamp_stack.length == 0, format!"Expected '%s' before end of attribute expression."(clamp_stack.empty ? '?' : clamp_stack[$-1]), loc);
1710 	return ctstrip(s[start .. idx]);
1711 }
1712 
1713 
1714 private string skipAttribString(in string s, ref size_t idx, char delimiter, in Location loc)
1715 @safe {
1716 	size_t start = idx;
1717 	while( idx < s.length ){
1718 		if( s[idx] == '\\' ){
1719 			// pass escape character through - will be handled later by buildInterpolatedString
1720 			idx++;
1721 			enforcep(idx < s.length, "'\\' must be followed by something (escaped character)!", loc);
1722 		} else if( s[idx] == delimiter ) break;
1723 		idx++;
1724 	}
1725 	enforcep(idx < s.length, format!"Unterminated attribute string: %s||"(s[start-1 .. $]), loc);
1726 	return s[start .. idx];
1727 }
1728 
1729 private bool matchesName(string filename, string logical_name, string parent_name)
1730 @safe {
1731 	if (filename == logical_name) return true;
1732 	auto ext = parent_name.ctExtension;
1733 	if (filename.ctendsWith(ext) && filename[0 .. $-ext.length] == logical_name) return true;
1734 	return false;
1735 }
1736 
1737 private Vector!T mapJoin(alias modify, T)(T[] arr)
1738 {
1739 	Vector!T ret;
1740 	size_t start = 0;
1741 	foreach (i; 0 .. arr.length) {
1742 		auto mod = modify(arr[i]);
1743 		if (mod.length > 0) {
1744 			ret ~= arr[start .. i];
1745 			ret ~= mod[];
1746 			start = i + 1;
1747 		}
1748 	}
1749 
1750 	if (start == 0) return Vector!T(arr);
1751 
1752 	ret ~= arr[start .. $];
1753 
1754 	return ret.move();
1755 }