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