1 /+
2 	== arsd.ini ==
3 	Copyright Elias Batek (0xEAB) 2025.
4 	Distributed under the Boost Software License, Version 1.0.
5 +/
6 /++
7 	INI configuration file support
8 
9 	This module provides a configurable INI parser with support for multiple
10 	“dialects” of the format.
11 
12 	### Getting started
13 
14 	$(LIST
15 		* [parseIniDocument] – Parses a string of INI data and stores the
16 		  result in a DOM-inspired [IniDocument] structure.
17 		* [parseIniAA] – Parses a string of INI data and stores the result
18 		  in an associative array (named sections) of associative arrays
19 		  (key/value pairs of the section).
20 		* [parseIniMergedAA] – Parses a string of INI data and stores the
21 		  result in a flat associative array (with all sections merged).
22 		* [stringifyIni] – Serializes an [IniDocument] or an associative array
23 		  to a string of data in INI format.
24 	)
25 
26 	---
27 	import arsd.ini;
28 
29 	IniDocument!string parseIniFile(string filePath) {
30 		import std.file : readText;
31 		return parseIniDocument(readText(filePath));
32 	}
33 	---
34 
35 	---
36 	import arsd.ini;
37 
38 	void writeIniFile(string filePath, IniDocument!string document) {
39 		import std.file : write;
40 		return write(filePath, stringifyIni(document));
41 	}
42 	---
43 
44 
45 	### On destructiveness and GC usage
46 
47 	Depending on the dialect and string type,
48 	[IniParser] can operate in one of these three modes:
49 
50 	$(LIST
51 		* Non-destructive with no heap alloc (incl. `@nogc`)
52 		* Non-destructive (uses the GC)
53 		* Destructive with no heap alloc (incl. `@nogc`)
54 	)
55 
56 	a) If a given dialect requests no mutation of the input data
57 	(i.e. no escape sequences, no concaternation of substrings etc.)
58 	and is therefore possible to implement with slicing operations only,
59 	the parser will be non-destructive and not do any heap allocations.
60 	Such a parser is verifiably `@nogc`, too.
61 
62 	b) In cases where a dialect requires data-mutating operations,
63 	there are two ways for a parser to implement them:
64 
65 	b.0) Either perform those mutations on the input data itself
66 	and alter the contents of that buffer.
67 	Because of the destructive nature of this operation,
68 	it can be performed only once safely.
69 	(Such an implementation could optionally fix up the modified data
70 	to become valid and parsable again.
71 	Though doing so would come with a performance overhead.)
72 
73 	b.1) Or allocate a new buffer for the result of the operation.
74 	This also has the advantage that it works with `immutable` and `const`
75 	input data.
76 	For convenience reasons the GC is used to perform such allocations.
77 
78 	Use [IniParser.isDestructive] to check for the operating mode.
79 
80 	The construct a non-destructive parser despite a mutable input data,
81 	specify `const(char)[]` as the value of the `string` template parameter.
82 
83 	---
84 	char[] mutableInput = [ /* … */ ];
85 	auto parser = makeIniParser!(dialect, const(char)[])(mutableInput);
86 	assert(parser.isDestructive == false);
87 	---
88  +/
89 module arsd.ini;
90 
91 ///
92 @safe unittest {
93 	// INI example data (e.g. from an `autorun.inf` file)
94 	static immutable string rawIniData =
95 		"[autorun]\n"
96 		~ "open=setup.exe\n"
97 		~ "icon=setup.exe,0\n";
98 
99 	// Parse the document into an associative array:
100 	string[string][string] data = parseIniAA(rawIniData);
101 
102 	string open = data["autorun"]["open"];
103 	string icon = data["autorun"]["icon"];
104 
105 	assert(open == "setup.exe");
106 	assert(icon == "setup.exe,0");
107 }
108 
109 ///
110 @safe unittest {
111 	// INI example data (e.g. from an `autorun.inf` file)
112 	static immutable string rawIniData =
113 		"[autorun]\n"
114 		~ "open=setup.exe\n"
115 		~ "icon=setup.exe,0\n";
116 
117 	// Parse the document into a flat associative array.
118 	// (Sections would get merged, but there is only one section in the
119 	// example anyway.)
120 	string[string] data = parseIniMergedAA(rawIniData);
121 
122 	string open = data["open"];
123 	string icon = data["icon"];
124 
125 	assert(open == "setup.exe");
126 	assert(icon == "setup.exe,0");
127 }
128 
129 ///
130 @safe unittest {
131 	// INI example data (e.g. from an `autorun.inf` file):
132 	static immutable string rawIniData =
133 		"[autorun]\n"
134 		~ "open=setup.exe\n"
135 		~ "icon=setup.exe,0\n";
136 
137 	// Parse the document.
138 	IniDocument!string document = parseIniDocument(rawIniData);
139 
140 	// Let’s search for the value of an entry `icon` in the `autorun` section.
141 	static string searchAutorunIcon(IniDocument!string document) {
142 		// Iterate over all sections.
143 		foreach (IniSection!string section; document.sections) {
144 
145 			// Search for the `[autorun]` section.
146 			if (section.name == "autorun") {
147 
148 				// Iterate over all items in the section.
149 				foreach (IniKeyValuePair!string item; section.items) {
150 
151 					// Search for the `icon` entry.
152 					if (item.key == "icon") {
153 						// Found!
154 						return item.value;
155 					}
156 				}
157 			}
158 		}
159 
160 		// Not found!
161 		return null;
162 	}
163 
164 	// Call our search function.
165 	string icon = searchAutorunIcon(document);
166 
167 	// Finally, verify the result.
168 	assert(icon == "setup.exe,0");
169 }
170 
171 /++
172 	Determines whether a type `T` is a string type compatible with this library.
173  +/
174 enum isCompatibleString(T) = (is(T == immutable(char)[]) || is(T == const(char)[]) || is(T == char[]));
175 
176 //dfmt off
177 /++
178 	Feature set to be understood by the parser.
179 
180 	---
181 	enum myDialect = (IniDialect.defaults | IniDialect.inlineComments);
182 	---
183  +/
184 enum IniDialect : ulong {
185 	/++
186 		Minimum feature set.
187 
188 		No comments, no extras, no nothing.
189 		Only sections, keys and values.
190 		Everything fits into these categories from a certain point of view.
191 	 +/
192 	lite                                    = 0,
193 
194 	/++
195 		Parse line comments (starting with `;`).
196 
197 		```ini
198 		; This is a line comment.
199 		;This one too.
200 
201 		key = value ;But this isn't one.
202 		```
203 	 +/
204 	lineComments                            = 0b_0000_0000_0000_0001,
205 
206 	/++
207 		Parse inline comments (starting with `;`).
208 
209 		```ini
210 		key1 = value2 ; Inline comment.
211 		key2 = value2 ;Inline comment.
212 		key3 = value3; Inline comment.
213 		;Not a true inline comment (but technically equivalent).
214 		```
215 	 +/
216 	inlineComments                          = 0b_0000_0000_0000_0011,
217 
218 	/++
219 		Parse line comments starting with `#`.
220 
221 		```ini
222 		# This is a comment.
223 		#Too.
224 		key = value # Not a line comment.
225 		```
226 	 +/
227 	hashLineComments                        = 0b_0000_0000_0000_0100,
228 
229 	/++
230 		Parse inline comments starting with `#`.
231 
232 		```ini
233 		key1 = value2 # Inline comment.
234 		key2 = value2 #Inline comment.
235 		key3 = value3# Inline comment.
236 		#Not a true inline comment (but technically equivalent).
237 		```
238 	 +/
239 	hashInlineComments                      = 0b_0000_0000_0000_1100,
240 
241 	/++
242 		Parse quoted strings.
243 
244 		```ini
245 		key1 = non-quoted value
246 		key2 = "quoted value"
247 
248 		"quoted key" = value
249 		non-quoted key = value
250 
251 		"another key" = "another value"
252 
253 		multi line = "line 1
254 		line 2"
255 		```
256 	 +/
257 	quotedStrings                           = 0b_0000_0000_0001_0000,
258 
259 	/++
260 		Parse quoted strings using single-quotes.
261 
262 		```ini
263 		key1 = non-quoted value
264 		key2 = 'quoted value'
265 
266 		'quoted key' = value
267 		non-quoted key = value
268 
269 		'another key' = 'another value'
270 
271 		multi line = 'line 1
272 		line 2'
273 		```
274 	 +/
275 	singleQuoteQuotedStrings                = 0b_0000_0000_0010_0000,
276 
277 	/++
278 		Parse key/value pairs separated with a colon (`:`).
279 
280 		```ini
281 		key: value
282 		key= value
283 		```
284 	 +/
285 	colonKeys                               = 0b_0000_0000_0100_0000,
286 
287 	/++
288 		Concats substrings and emits them as a single token.
289 
290 		$(LIST
291 			* For a mutable `char[]` input,
292 			  this will rewrite the data in the input array.
293 			* For a non-mutable `immutable(char)[]` (=`string`) or `const(char)[]` input,
294 			  this will allocate a new array with the GC.
295 		)
296 
297 		```ini
298 		key = "Value1" "Value2"
299 		; → Value1Value2
300 		```
301 	 +/
302 	concatSubstrings                        = 0b_0000_0001_0000_0000,
303 
304 	/++
305 		Evaluates escape sequences in the input string.
306 
307 		$(LIST
308 			* For a mutable `char[]` input,
309 			  this will rewrite the data in the input array.
310 			* For a non-mutable `immutable(char)[]` (=`string`) or `const(char)[]` input,
311 			  this will allocate a new array with the GC.
312 		)
313 
314 		$(SMALL_TABLE
315 			Special escape sequences
316 			`\\` | Backslash
317 			`\0` | Null character
318 			`\n` | Line feed
319 			`\r` | Carriage return
320 			`\t` | Tabulator
321 		)
322 
323 		```ini
324 		key1 = Line 1\nLine 2
325 		; → Line 1
326 		;   Line 2
327 
328 		key2 = One \\ and one \;
329 		; → One \ and one ;
330 		```
331 	 +/
332 	escapeSequences                         = 0b_0000_0010_0000_0000,
333 
334 	/++
335 		Folds lines on escaped linebreaks.
336 
337 		$(LIST
338 			* For a mutable `char[]` input,
339 			  this will rewrite the data in the input array.
340 			* For a non-mutable `immutable(char)[]` (=`string`) or `const(char)[]` input,
341 			  this will allocate a new array with the GC.
342 		)
343 
344 		```ini
345 		key1 = word1\
346 		word2
347 		; → word1word2
348 
349 		key2 = foo \
350 		bar
351 		; → foo bar
352 		```
353 	 +/
354 	lineFolding                             = 0b_0000_0100_0000_0000,
355 
356 	/++
357 		Imitates the behavior of the INI parser implementation found in PHP.
358 
359 		$(WARNING
360 			This preset may be adjusted without further notice in the future
361 			in cases where it increases alignment with PHP’s implementation.
362 		)
363 	 +/
364 	presetPhp                               = (
365 	                                              lineComments
366 	                                            | inlineComments
367 	                                            | hashLineComments
368 	                                            | hashInlineComments
369 	                                            | quotedStrings
370 	                                            | singleQuoteQuotedStrings
371 	                                            | concatSubstrings
372 	                                        ),
373 
374 	///
375 	presetDefaults                          = (
376 	                                              lineComments
377 	                                            | quotedStrings
378 	                                            | singleQuoteQuotedStrings
379 	                                        ),
380 
381 	///
382 	defaults = presetDefaults,
383 }
384 //dfmt on
385 
386 private bool hasFeature(ulong dialect, ulong feature) @safe pure nothrow @nogc {
387 	return ((dialect & feature) > 0);
388 }
389 
390 private T[] spliceImpl(T)(T[] array, size_t at, size_t count) @safe pure nothrow @nogc
391 in (at < array.length)
392 in (count <= array.length)
393 in (at + count <= array.length) {
394 	const upper = array.length - count;
395 
396 	for (size_t idx = at; idx < upper; ++idx) {
397 		array[idx] = array[idx + count];
398 	}
399 
400 	return array[0 .. ($ - count)];
401 }
402 
403 private T[] splice(T)(auto ref scope T[] array, size_t at, size_t count) @safe pure nothrow @nogc {
404 	static if (__traits(isRef, array)) {
405 		array = spliceImpl(array, at, count); // @suppress(dscanner.suspicious.auto_ref_assignment)
406 		return array;
407 	} else {
408 		return spliceImpl(array, at, count);
409 	}
410 }
411 
412 @safe unittest {
413 	assert("foobar".dup.splice(0, 0) == "foobar");
414 	assert("foobar".dup.splice(0, 6) == "");
415 	assert("foobar".dup.splice(0, 1) == "oobar");
416 	assert("foobar".dup.splice(1, 5) == "f");
417 	assert("foobar".dup.splice(1, 4) == "fr");
418 	assert("foobar".dup.splice(4, 1) == "foobr");
419 	assert("foobar".dup.splice(4, 2) == "foob");
420 }
421 
422 @safe unittest {
423 	char[] array = ['a', 's', 'd', 'f'];
424 	array.splice(1, 2);
425 	assert(array == "af");
426 }
427 
428 ///
429 char resolveIniEscapeSequence(char c) @safe pure nothrow @nogc {
430 	switch (c) {
431 	case 'n':
432 		return '\x0A';
433 	case 'r':
434 		return '\x0D';
435 	case 't':
436 		return '\x09';
437 	case '\\':
438 		return '\\';
439 	case '0':
440 		return '\x00';
441 
442 	default:
443 		return c;
444 	}
445 }
446 
447 ///
448 @safe unittest {
449 	assert(resolveIniEscapeSequence('n') == '\n');
450 	assert(resolveIniEscapeSequence('r') == '\r');
451 	assert(resolveIniEscapeSequence('t') == '\t');
452 	assert(resolveIniEscapeSequence('\\') == '\\');
453 	assert(resolveIniEscapeSequence('0') == '\0');
454 
455 	// Unsupported characters are preserved.
456 	assert(resolveIniEscapeSequence('a') == 'a');
457 	assert(resolveIniEscapeSequence('Z') == 'Z');
458 	assert(resolveIniEscapeSequence('1') == '1');
459 	// Unsupported special characters are preserved.
460 	assert(resolveIniEscapeSequence('@') == '@');
461 	// Line breaks are preserved.
462 	assert(resolveIniEscapeSequence('\n') == '\n');
463 	assert(resolveIniEscapeSequence('\r') == '\r');
464 	// UTF-8 is preserved.
465 	assert(resolveIniEscapeSequence("ü"[0]) == "ü"[0]);
466 }
467 
468 private struct StringRange {
469 	private {
470 		const(char)[] _data;
471 	}
472 
473 @safe pure nothrow @nogc:
474 
475 	public this(const(char)[] data) {
476 		_data = data;
477 	}
478 
479 	bool empty() const {
480 		return (_data.length == 0);
481 	}
482 
483 	char front() const {
484 		return _data[0];
485 	}
486 
487 	void popFront() {
488 		_data = _data[1 .. $];
489 	}
490 }
491 
492 private struct StringSliceRange {
493 	private {
494 		const(char)[] _data;
495 	}
496 
497 @safe pure nothrow @nogc:
498 
499 	public this(const(char)[] data) {
500 		_data = data;
501 	}
502 
503 	bool empty() const {
504 		return (_data.length == 0);
505 	}
506 
507 	const(char)[] front() const {
508 		return _data[0 .. 1];
509 	}
510 
511 	void popFront() {
512 		_data = _data[1 .. $];
513 	}
514 }
515 
516 /++
517 	Resolves escape sequences and performs line folding.
518 
519 	Feature set depends on the [Dialect].
520  +/
521 string resolveIniEscapeSequences(Dialect dialect)(const(char)[] input) @safe pure nothrow {
522 	size_t irrelevant = 0;
523 
524 	auto source = StringRange(input);
525 	determineIrrelevantLoop: while (!source.empty) {
526 		if (source.front != '\\') {
527 			source.popFront();
528 			continue;
529 		}
530 
531 		source.popFront();
532 		if (source.empty) {
533 			break;
534 		}
535 
536 		static if (dialect.hasFeature(Dialect.lineFolding)) {
537 			switch (source.front) {
538 			case '\n':
539 				source.popFront();
540 				irrelevant += 2;
541 				continue determineIrrelevantLoop;
542 
543 			case '\r':
544 				source.popFront();
545 				irrelevant += 2;
546 				if (source.empty) {
547 					break determineIrrelevantLoop;
548 				}
549 				// CRLF?
550 				if (source.front == '\n') {
551 					source.popFront();
552 					++irrelevant;
553 				}
554 				continue determineIrrelevantLoop;
555 
556 			default:
557 				break;
558 			}
559 		}
560 
561 		static if (dialect.hasFeature(Dialect.escapeSequences)) {
562 			source.popFront();
563 			++irrelevant;
564 		}
565 	}
566 
567 	const escapedSize = input.length - irrelevant;
568 	auto result = new char[](escapedSize);
569 
570 	size_t cursor = 0;
571 	source = StringRange(input);
572 	buildResultLoop: while (!source.empty) {
573 		if (source.front != '\\') {
574 			result[cursor++] = source.front;
575 			source.popFront();
576 			continue;
577 		}
578 
579 		source.popFront();
580 		if (source.empty) {
581 			result[cursor] = '\\';
582 			break;
583 		}
584 
585 		static if (dialect.hasFeature(Dialect.lineFolding)) {
586 			switch (source.front) {
587 			case '\n':
588 				source.popFront();
589 				continue buildResultLoop;
590 
591 			case '\r':
592 				source.popFront();
593 				if (source.empty) {
594 					break buildResultLoop;
595 				}
596 				// CRLF?
597 				if (source.front == '\n') {
598 					source.popFront();
599 				}
600 				continue buildResultLoop;
601 
602 			default:
603 				break;
604 			}
605 		}
606 
607 		static if (dialect.hasFeature(Dialect.escapeSequences)) {
608 			result[cursor++] = resolveIniEscapeSequence(source.front);
609 			source.popFront();
610 			continue;
611 		} else {
612 			result[cursor++] = '\\';
613 		}
614 	}
615 
616 	return result;
617 }
618 
619 ///
620 @safe unittest {
621 	enum none = Dialect.lite;
622 	enum escp = Dialect.escapeSequences;
623 	enum fold = Dialect.lineFolding;
624 	enum both = Dialect.escapeSequences | Dialect.lineFolding;
625 
626 	assert(resolveIniEscapeSequences!none("foo\\nbar") == "foo\\nbar");
627 	assert(resolveIniEscapeSequences!escp("foo\\nbar") == "foo\nbar");
628 	assert(resolveIniEscapeSequences!fold("foo\\nbar") == "foo\\nbar");
629 	assert(resolveIniEscapeSequences!both("foo\\nbar") == "foo\nbar");
630 
631 	assert(resolveIniEscapeSequences!none("foo\\\nbar") == "foo\\\nbar");
632 	assert(resolveIniEscapeSequences!escp("foo\\\nbar") == "foo\nbar");
633 	assert(resolveIniEscapeSequences!fold("foo\\\nbar") == "foobar");
634 	assert(resolveIniEscapeSequences!both("foo\\\nbar") == "foobar");
635 
636 	assert(resolveIniEscapeSequences!none("foo\\\n\\nbar") == "foo\\\n\\nbar");
637 	assert(resolveIniEscapeSequences!escp("foo\\\n\\nbar") == "foo\n\nbar");
638 	assert(resolveIniEscapeSequences!fold("foo\\\n\\nbar") == "foo\\nbar");
639 	assert(resolveIniEscapeSequences!both("foo\\\n\\nbar") == "foo\nbar");
640 
641 	assert(resolveIniEscapeSequences!none("foobar\\") == "foobar\\");
642 	assert(resolveIniEscapeSequences!escp("foobar\\") == "foobar\\");
643 	assert(resolveIniEscapeSequences!fold("foobar\\") == "foobar\\");
644 	assert(resolveIniEscapeSequences!both("foobar\\") == "foobar\\");
645 
646 	assert(resolveIniEscapeSequences!none("foo\\\r\nbar") == "foo\\\r\nbar");
647 	assert(resolveIniEscapeSequences!escp("foo\\\r\nbar") == "foo\r\nbar");
648 	assert(resolveIniEscapeSequences!fold("foo\\\r\nbar") == "foobar");
649 	assert(resolveIniEscapeSequences!both("foo\\\r\nbar") == "foobar");
650 
651 	assert(resolveIniEscapeSequences!none(`\nfoobar\n`) == "\\nfoobar\\n");
652 	assert(resolveIniEscapeSequences!escp(`\nfoobar\n`) == "\nfoobar\n");
653 	assert(resolveIniEscapeSequences!fold(`\nfoobar\n`) == "\\nfoobar\\n");
654 	assert(resolveIniEscapeSequences!both(`\nfoobar\n`) == "\nfoobar\n");
655 
656 	assert(resolveIniEscapeSequences!none("\\\nfoo \\\rba\\\r\nr") == "\\\nfoo \\\rba\\\r\nr");
657 	assert(resolveIniEscapeSequences!escp("\\\nfoo \\\rba\\\r\nr") == "\nfoo \rba\r\nr");
658 	assert(resolveIniEscapeSequences!fold("\\\nfoo \\\rba\\\r\nr") == "foo bar");
659 	assert(resolveIniEscapeSequences!both("\\\nfoo \\\rba\\\r\nr") == "foo bar");
660 }
661 
662 /++
663 	Type of a token (as output by the parser)
664  +/
665 public enum IniTokenType {
666 	/// indicates an error
667 	invalid = 0,
668 
669 	/// insignificant whitespace
670 	whitespace,
671 	/// section header opening bracket
672 	bracketOpen,
673 	/// section header closing bracket
674 	bracketClose,
675 	/// key/value separator, e.g. '='
676 	keyValueSeparator,
677 	/// line break, i.e. LF, CRLF or CR
678 	lineBreak,
679 
680 	/// text comment
681 	comment,
682 
683 	/// item key data
684 	key,
685 	/// item value data
686 	value,
687 	/// section name data
688 	sectionHeader,
689 }
690 
691 /++
692 	Token of INI data (as output by the parser)
693  +/
694 struct IniToken(string) if (isCompatibleString!string) {
695 	///
696 	IniTokenType type;
697 
698 	/++
699 		Content
700 	 +/
701 	string data;
702 }
703 
704 private alias TokenType = IniTokenType;
705 private alias Dialect = IniDialect;
706 
707 private enum LocationState {
708 	newLine,
709 	key,
710 	preValue,
711 	inValue,
712 	sectionHeader,
713 }
714 
715 private enum OperatingMode {
716 	nonDestructive,
717 	destructive,
718 }
719 
720 private enum OperatingMode operatingMode(string) = (is(string == char[]))
721 	? OperatingMode.destructive : OperatingMode.nonDestructive;
722 
723 /++
724 	Low-level INI parser
725 
726 	See_also:
727 		$(LIST
728 			* [IniFilteredParser]
729 			* [parseIniDocument]
730 			* [parseIniAA]
731 			* [parseIniMergedAA]
732 		)
733  +/
734 struct IniParser(
735 	IniDialect dialect = IniDialect.defaults,
736 	string = immutable(char)[],
737 ) if (isCompatibleString!string) {
738 
739 	public {
740 		///
741 		alias Token = IniToken!string;
742 
743 		// dfmt off
744 		///
745 		enum isDestructive = (
746 			(operatingMode!string == OperatingMode.destructive)
747 			&& (
748 				   dialect.hasFeature(Dialect.concatSubstrings)
749 				|| dialect.hasFeature(Dialect.escapeSequences)
750 				|| dialect.hasFeature(Dialect.lineFolding)
751 			)
752 		);
753 		// dfmt on
754 	}
755 
756 	private {
757 		string _source;
758 		Token _front;
759 		bool _empty = true;
760 
761 		LocationState _locationState = LocationState.newLine;
762 
763 		static if (dialect.hasFeature(Dialect.concatSubstrings)) {
764 			bool _bypassConcatSubstrings = false;
765 		}
766 	}
767 
768 @safe pure nothrow:
769 
770 	///
771 	public this(string rawIni) {
772 		_source = rawIni;
773 		_empty = false;
774 
775 		this.popFront();
776 	}
777 
778 	// Range API
779 	public {
780 
781 		///
782 		bool empty() const @nogc {
783 			return _empty;
784 		}
785 
786 		///
787 		inout(Token) front() inout @nogc {
788 			return _front;
789 		}
790 
791 		private void popFrontImpl() {
792 			if (_source.length == 0) {
793 				_empty = true;
794 				return;
795 			}
796 
797 			_front = this.fetchFront();
798 		}
799 
800 		/*
801 			This is a workaround.
802 			The compiler doesn’t feel like inferring `@nogc` properly otherwise.
803 
804 			→ cannot call non-@nogc function
805 				`arsd.ini.makeIniParser!(IniDialect.concatSubstrings, char[]).makeIniParser`
806 			→ which calls
807 				`arsd.ini.IniParser!(IniDialect.concatSubstrings, char[]).IniParser.this`
808 			→ which calls
809 				`arsd.ini.IniParser!(IniDialect.concatSubstrings, char[]).IniParser.popFront`
810 		 */
811 		static if (isDestructive) {
812 			///
813 			void popFront() @nogc {
814 				popFrontImpl();
815 			}
816 		} else {
817 			///
818 			void popFront() {
819 				popFrontImpl();
820 			}
821 		}
822 
823 		// Destructive parsers make very poor Forward Ranges.
824 		static if (!isDestructive) {
825 			///
826 			inout(typeof(this)) save() inout @nogc {
827 				return this;
828 			}
829 		}
830 	}
831 
832 	// extras
833 	public {
834 
835 		/++
836 			Skips tokens that are irrelevant for further processing
837 
838 			Returns:
839 				true = if there are no further tokens,
840 					i.e. whether the range is empty now
841 		 +/
842 		bool skipIrrelevant(bool skipComments = true) {
843 			static bool isIrrelevant(const TokenType type, const bool skipComments) {
844 				pragma(inline, true);
845 
846 				final switch (type) with (TokenType) {
847 				case invalid:
848 					return false;
849 
850 				case whitespace:
851 				case bracketOpen:
852 				case bracketClose:
853 				case keyValueSeparator:
854 				case lineBreak:
855 					return true;
856 
857 				case comment:
858 					return skipComments;
859 
860 				case sectionHeader:
861 				case key:
862 				case value:
863 					return false;
864 				}
865 			}
866 
867 			while (!this.empty) {
868 				const irrelevant = isIrrelevant(_front.type, skipComments);
869 
870 				if (!irrelevant) {
871 					return false;
872 				}
873 
874 				this.popFront();
875 			}
876 
877 			return true;
878 		}
879 	}
880 
881 	private {
882 
883 		bool isOnFinalChar() const @nogc {
884 			pragma(inline, true);
885 			return (_source.length == 1);
886 		}
887 
888 		bool isAtStartOfLineOrEquivalent() @nogc {
889 			return (_locationState == LocationState.newLine);
890 		}
891 
892 		Token makeToken(TokenType type, size_t length) @nogc {
893 			auto token = Token(type, _source[0 .. length]);
894 			_source = _source[length .. $];
895 			return token;
896 		}
897 
898 		Token makeToken(TokenType type, size_t length, size_t skip) @nogc {
899 			_source = _source[skip .. $];
900 			return this.makeToken(type, length);
901 		}
902 
903 		Token lexWhitespace() @nogc {
904 			foreach (immutable idxM1, const c; _source[1 .. $]) {
905 				switch (c) {
906 				case '\x09':
907 				case '\x0B':
908 				case '\x0C':
909 				case ' ':
910 					break;
911 
912 				default:
913 					return this.makeToken(TokenType.whitespace, (idxM1 + 1));
914 				}
915 			}
916 
917 			// all whitespace
918 			return this.makeToken(TokenType.whitespace, _source.length);
919 		}
920 
921 		Token lexComment() @nogc {
922 			foreach (immutable idxM1, const c; _source[1 .. $]) {
923 				switch (c) {
924 				default:
925 					break;
926 
927 				case '\x0A':
928 				case '\x0D':
929 					return this.makeToken(TokenType.comment, idxM1, 1);
930 				}
931 			}
932 
933 			return this.makeToken(TokenType.comment, (-1 + _source.length), 1);
934 		}
935 
936 		Token lexSubstringImpl(TokenType tokenType)() {
937 
938 			enum Result {
939 				end,
940 				endChomp,
941 				regular,
942 				whitespace,
943 				sequence,
944 			}
945 
946 			enum QuotedString : ubyte {
947 				none = 0,
948 				regular,
949 				single,
950 			}
951 
952 			// dfmt off
953 			enum bool hasAnyQuotedString = (
954 				   dialect.hasFeature(Dialect.quotedStrings)
955 				|| dialect.hasFeature(Dialect.singleQuoteQuotedStrings)
956 			);
957 
958 			enum bool hasAnyEscaping = (
959 				   dialect.hasFeature(Dialect.lineFolding)
960 				|| dialect.hasFeature(Dialect.escapeSequences)
961 			);
962 			// dfmt on
963 
964 			static if (hasAnyQuotedString) {
965 				auto inQuotedString = QuotedString.none;
966 			}
967 			static if (dialect.hasFeature(Dialect.quotedStrings)) {
968 				if (_source[0] == '"') {
969 					inQuotedString = QuotedString.regular;
970 
971 					// chomp quote initiator
972 					_source = _source[1 .. $];
973 				}
974 			}
975 			static if (dialect.hasFeature(Dialect.singleQuoteQuotedStrings)) {
976 				if (_source[0] == '\'') {
977 					inQuotedString = QuotedString.single;
978 
979 					// chomp quote initiator
980 					_source = _source[1 .. $];
981 				}
982 			}
983 			static if (!hasAnyQuotedString) {
984 				enum inQuotedString = QuotedString.none;
985 			}
986 
987 			Result nextChar(const char c) @safe pure nothrow @nogc {
988 				pragma(inline, true);
989 
990 				switch (c) {
991 				default:
992 					return Result.regular;
993 
994 				case '\x09':
995 				case '\x0B':
996 				case '\x0C':
997 				case ' ':
998 					return (inQuotedString != QuotedString.none)
999 						? Result.regular : Result.whitespace;
1000 
1001 				case '\x0A':
1002 				case '\x0D':
1003 					return (inQuotedString != QuotedString.none)
1004 						? Result.regular : Result.endChomp;
1005 
1006 				case '"':
1007 					static if (dialect.hasFeature(Dialect.quotedStrings)) {
1008 						// dfmt off
1009 						return (inQuotedString == QuotedString.regular)
1010 							? Result.end
1011 							: (inQuotedString == QuotedString.single)
1012 								? Result.regular
1013 								: Result.endChomp;
1014 						// dfmt on
1015 					} else {
1016 						return Result.regular;
1017 					}
1018 
1019 				case '\'':
1020 					static if (dialect.hasFeature(Dialect.singleQuoteQuotedStrings)) {
1021 						return (inQuotedString != QuotedString.regular)
1022 							? Result.end : Result.regular;
1023 					} else {
1024 						return Result.regular;
1025 					}
1026 
1027 				case '#':
1028 					if (dialect.hasFeature(Dialect.hashInlineComments)) {
1029 						return (inQuotedString != QuotedString.none)
1030 							? Result.regular : Result.endChomp;
1031 					} else {
1032 						return Result.regular;
1033 					}
1034 
1035 				case ';':
1036 					if (dialect.hasFeature(Dialect.inlineComments)) {
1037 						return (inQuotedString != QuotedString.none)
1038 							? Result.regular : Result.endChomp;
1039 					} else {
1040 						return Result.regular;
1041 					}
1042 
1043 				case ':':
1044 					static if (dialect.hasFeature(Dialect.colonKeys)) {
1045 						goto case '=';
1046 					} else {
1047 						return Result.regular;
1048 					}
1049 
1050 				case '=':
1051 					static if (tokenType == TokenType.key) {
1052 						return (inQuotedString != QuotedString.none)
1053 							? Result.regular : Result.end;
1054 					} else {
1055 						return Result.regular;
1056 					}
1057 
1058 				case '\\':
1059 					static if (hasAnyEscaping) {
1060 						return Result.sequence;
1061 					} else {
1062 						goto default;
1063 					}
1064 
1065 				case ']':
1066 					static if (tokenType == TokenType.sectionHeader) {
1067 						return (inQuotedString != QuotedString.none)
1068 							? Result.regular : Result.end;
1069 					} else {
1070 						return Result.regular;
1071 					}
1072 				}
1073 
1074 				assert(false, "Bug: This should have been unreachable.");
1075 			}
1076 
1077 			ptrdiff_t idxLastText = -1;
1078 			ptrdiff_t idxCutoff = -1;
1079 
1080 			for (size_t idx = 0; idx < _source.length; ++idx) {
1081 				const c = _source[idx];
1082 				const status = nextChar(c);
1083 
1084 				if (status == Result.end) {
1085 					if (idxLastText < 0) {
1086 						idxLastText = (idx - 1);
1087 					}
1088 					break;
1089 				} else if (status == Result.endChomp) {
1090 					idxCutoff = idx;
1091 					break;
1092 				} else if (status == Result.whitespace) {
1093 					continue;
1094 				} else if (status == Result.sequence) {
1095 					static if (hasAnyEscaping) {
1096 						const idxNext = idx + 1;
1097 						if (idxNext < _source.length) {
1098 							static if (dialect.hasFeature(Dialect.lineFolding)) {
1099 								size_t determineFoldingCount() {
1100 									switch (_source[idxNext]) {
1101 									case '\n':
1102 										return 2;
1103 
1104 									case '\r':
1105 										const idxAfterNext = idxNext + 1;
1106 										// CRLF?
1107 										if (idxAfterNext < _source.length) {
1108 											if (_source[idxAfterNext] == '\n') {
1109 												return 3;
1110 											}
1111 										}
1112 										return 2;
1113 
1114 									default:
1115 										return 0;
1116 									}
1117 
1118 									assert(false, "Bug: This should have been unreachable.");
1119 								}
1120 
1121 								const foldingCount = determineFoldingCount();
1122 								if (foldingCount > 0) {
1123 									static if (operatingMode!string == OperatingMode.nonDestructive) {
1124 										idx += (foldingCount - 1);
1125 										idxCutoff = idx;
1126 									}
1127 									static if (operatingMode!string == OperatingMode.destructive) {
1128 										_source.splice(idx, foldingCount);
1129 										idx -= (foldingCount - 1);
1130 									}
1131 									continue;
1132 								}
1133 							}
1134 							static if (dialect.hasFeature(Dialect.escapeSequences)) {
1135 								static if (operatingMode!string == OperatingMode.nonDestructive) {
1136 									++idx;
1137 								}
1138 								static if (operatingMode!string == OperatingMode.destructive) {
1139 									_source[idx] = resolveIniEscapeSequence(_source[idxNext]);
1140 									_source.splice(idxNext, 1);
1141 								}
1142 
1143 								idxLastText = idx;
1144 								continue;
1145 							}
1146 						}
1147 					}
1148 				}
1149 
1150 				idxLastText = idx;
1151 			}
1152 
1153 			const idxEOT = (idxLastText + 1);
1154 			auto token = Token(tokenType, _source[0 .. idxEOT]);
1155 
1156 			static if (hasAnyEscaping) {
1157 				static if (operatingMode!string == OperatingMode.nonDestructive) {
1158 					token.data = resolveIniEscapeSequences!dialect(token.data);
1159 				}
1160 			}
1161 
1162 			// "double-quote quoted": cut off any whitespace afterwards
1163 			if (inQuotedString == QuotedString.regular) {
1164 				const idxEOQ = (idxEOT + 1);
1165 				if (_source.length > idxEOQ) {
1166 					foreach (immutable idx, c; _source[idxEOQ .. $]) {
1167 						switch (c) {
1168 						case '\x09':
1169 						case '\x0B':
1170 						case '\x0C':
1171 						case ' ':
1172 							continue;
1173 
1174 						default:
1175 							// EOT because Q is cut off later
1176 							idxCutoff = idxEOT + idx;
1177 							break;
1178 						}
1179 						break;
1180 					}
1181 				}
1182 			}
1183 
1184 			const idxNextToken = (idxCutoff >= idxLastText) ? idxCutoff : idxEOT;
1185 			_source = _source[idxNextToken .. $];
1186 
1187 			if (inQuotedString != QuotedString.none) {
1188 				if (_source.length > 0) {
1189 					// chomp quote terminator
1190 					_source = _source[1 .. $];
1191 				}
1192 			}
1193 
1194 			return token;
1195 		}
1196 
1197 		Token lexSubstring() {
1198 			final switch (_locationState) {
1199 			case LocationState.newLine:
1200 			case LocationState.key:
1201 				return this.lexSubstringImpl!(TokenType.key);
1202 
1203 			case LocationState.preValue:
1204 				_locationState = LocationState.inValue;
1205 				goto case LocationState.inValue;
1206 
1207 			case LocationState.inValue:
1208 				return this.lexSubstringImpl!(TokenType.value);
1209 
1210 			case LocationState.sectionHeader:
1211 				return this.lexSubstringImpl!(TokenType.sectionHeader);
1212 			}
1213 		}
1214 
1215 		static if (dialect.hasFeature(Dialect.concatSubstrings)) {
1216 			Token lexSubstringsImpl(TokenType tokenType)() {
1217 				static if (operatingMode!string == OperatingMode.destructive) {
1218 					auto originalSource = _source;
1219 				}
1220 
1221 				Token token = this.lexSubstringImpl!tokenType();
1222 
1223 				auto next = this; // copy
1224 				next._bypassConcatSubstrings = true;
1225 				next.popFront();
1226 
1227 				static if (operatingMode!string == OperatingMode.destructive) {
1228 					import arsd.core : isSliceOf;
1229 
1230 					if (!token.data.isSliceOf(originalSource)) {
1231 						assert(false, "Memory corruption bug.");
1232 					}
1233 
1234 					const ptrdiff_t tokenDataOffset = (() @trusted => token.data.ptr - originalSource.ptr)();
1235 					auto mutSource = originalSource[tokenDataOffset .. $];
1236 					size_t mutOffset = token.data.length;
1237 				}
1238 
1239 				while (!next.empty) {
1240 					if (next.front.type != tokenType) {
1241 						break;
1242 					}
1243 
1244 					static if (operatingMode!string == OperatingMode.nonDestructive) {
1245 						token.data ~= next.front.data;
1246 					}
1247 					static if (operatingMode!string == OperatingMode.destructive) {
1248 						foreach (const c; next.front.data) {
1249 							mutSource[mutOffset] = c;
1250 							++mutOffset;
1251 						}
1252 						token.data = mutSource[0 .. mutOffset];
1253 					}
1254 
1255 					_source = next._source;
1256 					_locationState = next._locationState;
1257 					next.popFront();
1258 				}
1259 
1260 				return token;
1261 			}
1262 
1263 			Token lexSubstrings() {
1264 				final switch (_locationState) {
1265 				case LocationState.newLine:
1266 				case LocationState.key:
1267 					return this.lexSubstringsImpl!(TokenType.key);
1268 
1269 				case LocationState.preValue:
1270 					_locationState = LocationState.inValue;
1271 					goto case LocationState.inValue;
1272 
1273 				case LocationState.inValue:
1274 					return this.lexSubstringsImpl!(TokenType.value);
1275 
1276 				case LocationState.sectionHeader:
1277 					return this.lexSubstringsImpl!(TokenType.sectionHeader);
1278 				}
1279 			}
1280 		}
1281 
1282 		Token lexText() {
1283 			static if (dialect.hasFeature(Dialect.concatSubstrings)) {
1284 				if (!_bypassConcatSubstrings) {
1285 					return this.lexSubstrings();
1286 				}
1287 			}
1288 
1289 			return this.lexSubstring();
1290 		}
1291 
1292 		Token fetchFront() {
1293 			switch (_source[0]) {
1294 
1295 			default:
1296 				return this.lexText();
1297 
1298 			case '\x0A': {
1299 					_locationState = LocationState.newLine;
1300 					return this.makeToken(TokenType.lineBreak, 1);
1301 				}
1302 
1303 			case '\x0D': {
1304 					_locationState = LocationState.newLine;
1305 
1306 					// CR<EOF>?
1307 					if (this.isOnFinalChar) {
1308 						return this.makeToken(TokenType.lineBreak, 1);
1309 					}
1310 
1311 					// CRLF?
1312 					if (_source[1] == '\x0A') {
1313 						return this.makeToken(TokenType.lineBreak, 2);
1314 					}
1315 
1316 					// CR
1317 					return this.makeToken(TokenType.lineBreak, 1);
1318 				}
1319 
1320 			case '\x09':
1321 			case '\x0B':
1322 			case '\x0C':
1323 			case ' ':
1324 				if (_locationState == LocationState.inValue) {
1325 					return this.lexText();
1326 				}
1327 				return this.lexWhitespace();
1328 
1329 			case ':':
1330 				static if (dialect.hasFeature(Dialect.colonKeys)) {
1331 					goto case '=';
1332 				} else {
1333 					return this.lexText();
1334 				}
1335 
1336 			case '=':
1337 				_locationState = LocationState.preValue;
1338 				return this.makeToken(TokenType.keyValueSeparator, 1);
1339 
1340 			case '[':
1341 				_locationState = LocationState.sectionHeader;
1342 				return this.makeToken(TokenType.bracketOpen, 1);
1343 
1344 			case ']':
1345 				_locationState = LocationState.key;
1346 				return this.makeToken(TokenType.bracketClose, 1);
1347 
1348 			case ';': {
1349 					static if (dialect.hasFeature(Dialect.inlineComments)) {
1350 						return this.lexComment();
1351 					} else static if (dialect.hasFeature(Dialect.lineComments)) {
1352 						if (this.isAtStartOfLineOrEquivalent) {
1353 							return this.lexComment();
1354 						}
1355 						return this.lexText();
1356 					} else {
1357 						return this.lexText();
1358 					}
1359 				}
1360 
1361 			case '#': {
1362 					static if (dialect.hasFeature(Dialect.hashInlineComments)) {
1363 						return this.lexComment();
1364 					} else static if (dialect.hasFeature(Dialect.hashLineComments)) {
1365 						if (this.isAtStartOfLineOrEquivalent) {
1366 							return this.lexComment();
1367 						}
1368 						return this.lexText();
1369 					} else {
1370 						return this.lexText();
1371 					}
1372 				}
1373 			}
1374 		}
1375 	}
1376 }
1377 
1378 /++
1379 	Low-level INI parser with filtered output
1380 
1381 	This wrapper will only supply tokens of these types:
1382 
1383 	$(LIST
1384 		* IniTokenType.key
1385 		* IniTokenType.value
1386 		* IniTokenType.sectionHeader
1387 		* IniTokenType.invalid
1388 	)
1389 
1390 	See_also:
1391 		$(LIST
1392 			* [IniParser]
1393 			* [parseIniDocument]
1394 			* [parseIniAA]
1395 			* [parseIniMergedAA]
1396 		)
1397  +/
1398 struct IniFilteredParser(
1399 	IniDialect dialect = IniDialect.defaults,
1400 	string = immutable(char)[],
1401 ) {
1402 	///
1403 	public alias Token = IniToken!string;
1404 
1405 	///
1406 	public enum isDestructive = IniParser!(dialect, string).isDestructive;
1407 
1408 	private IniParser!(dialect, string) _parser;
1409 
1410 public @safe pure nothrow:
1411 
1412 	///
1413 	public this(IniParser!(dialect, string) parser) {
1414 		_parser = parser;
1415 		_parser.skipIrrelevant(true);
1416 	}
1417 
1418 	///
1419 	public this(string rawIni) {
1420 		auto parser = IniParser!(dialect, string)(rawIni);
1421 		this(parser);
1422 	}
1423 
1424 	///
1425 	bool empty() const @nogc => _parser.empty;
1426 
1427 	///
1428 	inout(Token) front() inout @nogc => _parser.front;
1429 
1430 	///
1431 	void popFront() {
1432 		_parser.popFront();
1433 		_parser.skipIrrelevant(true);
1434 	}
1435 
1436 	static if (!isDestructive) {
1437 		///
1438 		inout(typeof(this)) save() inout @nogc {
1439 			return this;
1440 		}
1441 	}
1442 }
1443 
1444 ///
1445 @safe @nogc unittest {
1446 	// INI document (demo data)
1447 	static immutable string rawIniDocument = `; This is a comment.
1448 [section1]
1449 foo = bar ;another comment
1450 oachkatzl = schwoaf ;try pronouncing that
1451 `;
1452 
1453 	// Combine feature flags to build the required dialect.
1454 	const myDialect = (IniDialect.defaults | IniDialect.inlineComments);
1455 
1456 	// Instantiate a new parser and supply our document string.
1457 	auto parser = IniParser!(myDialect)(rawIniDocument);
1458 
1459 	int comments = 0;
1460 	int sections = 0;
1461 	int keys = 0;
1462 	int values = 0;
1463 
1464 	// Process token by token.
1465 	foreach (const parser.Token token; parser) {
1466 		if (token.type == IniTokenType.comment) {
1467 			++comments;
1468 		}
1469 		if (token.type == IniTokenType.sectionHeader) {
1470 			++sections;
1471 		}
1472 		if (token.type == IniTokenType.key) {
1473 			++keys;
1474 		}
1475 		if (token.type == IniTokenType.value) {
1476 			++values;
1477 		}
1478 	}
1479 
1480 	assert(comments == 3);
1481 	assert(sections == 1);
1482 	assert(keys == 2);
1483 	assert(values == 2);
1484 }
1485 
1486 @safe @nogc unittest {
1487 	static immutable string rawIniDocument = `; This is a comment.
1488 [section1]
1489 s1key1 = value1
1490 s1key2 = value2
1491 
1492 ; Another comment
1493 
1494 [section no.2]
1495 s2key1  = "value3"
1496 s2key2	 =	 value no.4
1497 `;
1498 
1499 	auto parser = IniParser!()(rawIniDocument);
1500 	alias Token = typeof(parser).Token;
1501 
1502 	{
1503 		assert(!parser.empty);
1504 		assert(parser.front == Token(TokenType.comment, " This is a comment."));
1505 
1506 		parser.popFront();
1507 		assert(!parser.empty);
1508 		assert(parser.front.type == TokenType.lineBreak);
1509 	}
1510 
1511 	{
1512 		parser.popFront();
1513 		assert(!parser.empty);
1514 		assert(parser.front == Token(TokenType.bracketOpen, "["));
1515 
1516 		parser.popFront();
1517 		assert(!parser.empty);
1518 		assert(parser.front == Token(TokenType.sectionHeader, "section1"));
1519 
1520 		parser.popFront();
1521 		assert(!parser.empty);
1522 		assert(parser.front == Token(TokenType.bracketClose, "]"));
1523 
1524 		parser.popFront();
1525 		assert(!parser.empty);
1526 		assert(parser.front.type == TokenType.lineBreak);
1527 	}
1528 
1529 	{
1530 		parser.popFront();
1531 		assert(!parser.empty);
1532 		assert(parser.front == Token(TokenType.key, "s1key1"));
1533 
1534 		parser.popFront();
1535 		assert(!parser.empty);
1536 		assert(parser.front == Token(TokenType.whitespace, " "));
1537 
1538 		parser.popFront();
1539 		assert(!parser.empty);
1540 		assert(parser.front == Token(TokenType.keyValueSeparator, "="));
1541 
1542 		parser.popFront();
1543 		assert(!parser.empty);
1544 		assert(parser.front == Token(TokenType.whitespace, " "));
1545 
1546 		parser.popFront();
1547 		assert(!parser.empty);
1548 		assert(parser.front == Token(TokenType.value, "value1"));
1549 
1550 		parser.popFront();
1551 		assert(!parser.empty);
1552 		assert(parser.front.type == TokenType.lineBreak);
1553 	}
1554 
1555 	{
1556 		parser.popFront();
1557 		assert(!parser.empty);
1558 		assert(parser.front == Token(TokenType.key, "s1key2"));
1559 
1560 		parser.popFront();
1561 		assert(!parser.skipIrrelevant());
1562 		assert(!parser.empty);
1563 		assert(parser.front == Token(TokenType.value, "value2"), parser.front.data);
1564 
1565 		parser.popFront();
1566 		assert(!parser.empty);
1567 		assert(parser.front.type == TokenType.lineBreak);
1568 	}
1569 
1570 	{
1571 		assert(!parser.skipIrrelevant());
1572 		assert(!parser.empty);
1573 		assert(parser.front == Token(TokenType.sectionHeader, "section no.2"));
1574 	}
1575 
1576 	{
1577 		parser.popFront();
1578 		assert(!parser.skipIrrelevant());
1579 		assert(!parser.empty);
1580 		assert(parser.front == Token(TokenType.key, "s2key1"));
1581 
1582 		parser.popFront();
1583 		assert(!parser.skipIrrelevant());
1584 		assert(!parser.empty);
1585 		assert(parser.front == Token(TokenType.value, "value3"));
1586 	}
1587 
1588 	{
1589 		parser.popFront();
1590 		assert(!parser.skipIrrelevant());
1591 		assert(!parser.empty);
1592 		assert(parser.front == Token(TokenType.key, "s2key2"));
1593 
1594 		parser.popFront();
1595 		assert(!parser.skipIrrelevant());
1596 		assert(!parser.empty);
1597 		assert(parser.front == Token(TokenType.value, "value no.4"));
1598 	}
1599 
1600 	parser.popFront();
1601 	assert(parser.skipIrrelevant());
1602 	assert(parser.empty());
1603 }
1604 
1605 @safe @nogc unittest {
1606 	static immutable rawIni = "#not-a = comment";
1607 	auto parser = makeIniParser(rawIni);
1608 
1609 	assert(!parser.empty);
1610 	assert(parser.front == parser.Token(TokenType.key, "#not-a"));
1611 
1612 	parser.popFront();
1613 	assert(!parser.skipIrrelevant());
1614 	assert(parser.front == parser.Token(TokenType.value, "comment"));
1615 
1616 	parser.popFront();
1617 	assert(parser.empty);
1618 }
1619 
1620 @safe @nogc unittest {
1621 	static immutable rawIni = "; only a comment";
1622 
1623 	auto regularParser = makeIniParser(rawIni);
1624 	auto filteredParser = makeIniFilteredParser(rawIni);
1625 
1626 	assert(!regularParser.empty);
1627 	assert(filteredParser.empty);
1628 }
1629 
1630 @safe @nogc unittest {
1631 	static immutable rawIni = "#actually_a = comment\r\n\t#another one\r\n\t\t ; oh, and a third one";
1632 	enum dialect = (Dialect.hashLineComments | Dialect.lineComments);
1633 	auto parser = makeIniParser!dialect(rawIni);
1634 
1635 	assert(!parser.empty);
1636 	assert(parser.front == parser.Token(TokenType.comment, "actually_a = comment"));
1637 
1638 	parser.popFront();
1639 	assert(!parser.skipIrrelevant(false));
1640 	assert(parser.front == parser.Token(TokenType.comment, "another one"));
1641 
1642 	parser.popFront();
1643 	assert(!parser.skipIrrelevant(false));
1644 	assert(parser.front == parser.Token(TokenType.comment, " oh, and a third one"));
1645 
1646 	parser.popFront();
1647 	assert(parser.empty);
1648 }
1649 
1650 @safe @nogc unittest {
1651 	static immutable rawIni = ";not a = line comment\nkey = value ;not-a-comment \nfoo = bar # not a comment\t";
1652 	enum dialect = Dialect.lite;
1653 	auto parser = makeIniParser!dialect(rawIni);
1654 
1655 	{
1656 		assert(!parser.empty);
1657 		assert(parser.front == parser.Token(TokenType.key, ";not a"));
1658 
1659 		parser.popFront();
1660 		assert(!parser.skipIrrelevant());
1661 		assert(parser.front == parser.Token(TokenType.value, "line comment"));
1662 	}
1663 
1664 	{
1665 		parser.popFront();
1666 		assert(!parser.skipIrrelevant());
1667 		assert(parser.front.type == TokenType.key);
1668 
1669 		parser.popFront();
1670 		assert(!parser.skipIrrelevant());
1671 		assert(parser.front == parser.Token(TokenType.value, "value ;not-a-comment"));
1672 	}
1673 
1674 	{
1675 		parser.popFront();
1676 		assert(!parser.skipIrrelevant());
1677 		assert(parser.front.type == TokenType.key);
1678 
1679 		parser.popFront();
1680 		assert(!parser.skipIrrelevant());
1681 		assert(parser.front == parser.Token(TokenType.value, "bar # not a comment"));
1682 	}
1683 }
1684 
1685 @safe @nogc unittest {
1686 	static immutable rawIni = "; line comment 0\t\n\nkey = value ; comment-1\nfoo = bar #comment 2\n";
1687 	enum dialect = (Dialect.inlineComments | Dialect.hashInlineComments);
1688 	auto parser = makeIniParser!dialect(rawIni);
1689 
1690 	{
1691 		assert(!parser.empty);
1692 		assert(parser.front == parser.Token(TokenType.comment, " line comment 0\t"));
1693 	}
1694 
1695 	{
1696 		parser.popFront();
1697 		assert(!parser.skipIrrelevant(false));
1698 		assert(parser.front.type == TokenType.key);
1699 
1700 		parser.popFront();
1701 		assert(!parser.skipIrrelevant(false));
1702 		assert(parser.front == parser.Token(TokenType.value, "value"));
1703 
1704 		parser.popFront();
1705 		assert(!parser.skipIrrelevant(false));
1706 		assert(parser.front == parser.Token(TokenType.comment, " comment-1"));
1707 	}
1708 
1709 	{
1710 		parser.popFront();
1711 		assert(!parser.skipIrrelevant(false));
1712 		assert(parser.front.type == TokenType.key);
1713 
1714 		parser.popFront();
1715 		assert(!parser.skipIrrelevant(false));
1716 		assert(parser.front == parser.Token(TokenType.value, "bar"));
1717 
1718 		parser.popFront();
1719 		assert(!parser.skipIrrelevant(false));
1720 		assert(parser.front == parser.Token(TokenType.comment, "comment 2"));
1721 	}
1722 
1723 	parser.popFront();
1724 	assert(parser.skipIrrelevant(false));
1725 }
1726 
1727 @safe @nogc unittest {
1728 	static immutable rawIni = "key = value;inline";
1729 	enum dialect = Dialect.inlineComments;
1730 	auto parser = makeIniParser!dialect(rawIni);
1731 
1732 	assert(!parser.empty);
1733 	parser.front == parser.Token(TokenType.key, "key");
1734 
1735 	parser.popFront();
1736 	assert(!parser.skipIrrelevant(false));
1737 	parser.front == parser.Token(TokenType.value, "value");
1738 
1739 	parser.popFront();
1740 	assert(!parser.skipIrrelevant(false));
1741 	parser.front == parser.Token(TokenType.comment, "inline");
1742 
1743 	parser.popFront();
1744 	assert(parser.empty);
1745 }
1746 
1747 @safe @nogc unittest {
1748 	static immutable rawIni = "key: value\n"
1749 		~ "foo= bar\n"
1750 		~ "lol :rofl\n"
1751 		~ "Oachkatzl : -Schwoaf\n"
1752 		~ `"Schüler:innen": 10`;
1753 	enum dialect = (Dialect.colonKeys | Dialect.quotedStrings);
1754 	auto parser = makeIniParser!dialect(rawIni);
1755 
1756 	{
1757 		assert(!parser.empty);
1758 		assert(parser.front == parser.Token(TokenType.key, "key"));
1759 
1760 		parser.popFront();
1761 		assert(!parser.skipIrrelevant());
1762 		assert(parser.front == parser.Token(TokenType.value, "value"));
1763 
1764 	}
1765 
1766 	{
1767 		parser.popFront();
1768 		assert(!parser.skipIrrelevant());
1769 		assert(parser.front == parser.Token(TokenType.key, "foo"));
1770 
1771 		parser.popFront();
1772 		assert(!parser.skipIrrelevant());
1773 		assert(parser.front == parser.Token(TokenType.value, "bar"));
1774 	}
1775 
1776 	{
1777 		parser.popFront();
1778 		assert(!parser.skipIrrelevant());
1779 		assert(parser.front == parser.Token(TokenType.key, "lol"));
1780 
1781 		parser.popFront();
1782 		assert(!parser.skipIrrelevant());
1783 		assert(parser.front == parser.Token(TokenType.value, "rofl"));
1784 	}
1785 
1786 	{
1787 		parser.popFront();
1788 		assert(!parser.skipIrrelevant());
1789 		assert(parser.front == parser.Token(TokenType.key, "Oachkatzl"));
1790 
1791 		parser.popFront();
1792 		assert(!parser.skipIrrelevant());
1793 		assert(parser.front == parser.Token(TokenType.value, "-Schwoaf"));
1794 	}
1795 
1796 	{
1797 		parser.popFront();
1798 		assert(!parser.skipIrrelevant());
1799 		assert(parser.front == parser.Token(TokenType.key, "Schüler:innen"));
1800 
1801 		parser.popFront();
1802 		assert(!parser.skipIrrelevant());
1803 		assert(parser.front == parser.Token(TokenType.value, "10"));
1804 	}
1805 
1806 	parser.popFront();
1807 	assert(parser.skipIrrelevant());
1808 }
1809 
1810 @safe @nogc unittest {
1811 	static immutable rawIni =
1812 		"\"foo=bar\"=foobar\n"
1813 		~ "'foo = bar' = foo_bar\n"
1814 		~ "foo = \"bar\"\n"
1815 		~ "foo = 'bar'\n"
1816 		~ "foo = ' bar '\n"
1817 		~ "foo = \" bar \"\n"
1818 		~ "multi_line = 'line1\nline2'\n"
1819 		~ "syntax = \"error";
1820 	enum dialect = (Dialect.quotedStrings | Dialect.singleQuoteQuotedStrings);
1821 	auto parser = makeIniFilteredParser!dialect(rawIni);
1822 
1823 	{
1824 		assert(!parser.empty);
1825 		assert(parser.front == parser.Token(TokenType.key, "foo=bar"));
1826 
1827 		parser.popFront();
1828 		assert(!parser.empty);
1829 		assert(parser.front == parser.Token(TokenType.value, "foobar"));
1830 
1831 	}
1832 
1833 	{
1834 		parser.popFront();
1835 		assert(!parser.empty);
1836 		assert(parser.front == parser.Token(TokenType.key, "foo = bar"));
1837 
1838 		parser.popFront();
1839 		assert(!parser.empty);
1840 		assert(parser.front == parser.Token(TokenType.value, "foo_bar"));
1841 	}
1842 
1843 	{
1844 		parser.popFront();
1845 		assert(!parser.empty);
1846 		assert(parser.front == parser.Token(TokenType.key, "foo"));
1847 
1848 		parser.popFront();
1849 		assert(!parser.empty);
1850 		assert(parser.front == parser.Token(TokenType.value, "bar"));
1851 	}
1852 
1853 	{
1854 		parser.popFront();
1855 		assert(!parser.empty);
1856 		assert(parser.front == parser.Token(TokenType.key, "foo"));
1857 
1858 		parser.popFront();
1859 		assert(!parser.empty);
1860 		assert(parser.front == parser.Token(TokenType.value, "bar"));
1861 	}
1862 
1863 	{
1864 		parser.popFront();
1865 		assert(!parser.empty);
1866 		assert(parser.front == parser.Token(TokenType.key, "foo"));
1867 
1868 		parser.popFront();
1869 		assert(!parser.empty);
1870 		assert(parser.front == parser.Token(TokenType.value, " bar "));
1871 	}
1872 
1873 	{
1874 		parser.popFront();
1875 		assert(!parser.empty);
1876 		assert(parser.front == parser.Token(TokenType.key, "foo"));
1877 
1878 		parser.popFront();
1879 		assert(!parser.empty);
1880 		assert(parser.front == parser.Token(TokenType.value, " bar "));
1881 	}
1882 
1883 	{
1884 		parser.popFront();
1885 		assert(!parser.empty);
1886 		assert(parser.front == parser.Token(TokenType.key, "multi_line"));
1887 
1888 		parser.popFront();
1889 		assert(!parser.empty);
1890 		assert(parser.front == parser.Token(TokenType.value, "line1\nline2"));
1891 	}
1892 
1893 	{
1894 		parser.popFront();
1895 		assert(!parser.empty);
1896 		assert(parser.front == parser.Token(TokenType.key, "syntax"));
1897 
1898 		parser.popFront();
1899 		assert(!parser.empty);
1900 		assert(parser.front == parser.Token(TokenType.value, "error"));
1901 	}
1902 
1903 	parser.popFront();
1904 	assert(parser.empty);
1905 }
1906 
1907 @safe unittest {
1908 	char[] rawIni = `
1909 key = \nvalue\n
1910 key = foo\t bar
1911 key\0key = value
1912 key \= = value
1913 `.dup;
1914 	enum dialect = Dialect.escapeSequences;
1915 	auto parser = makeIniFilteredParser!dialect(rawIni);
1916 
1917 	{
1918 		assert(!parser.empty);
1919 		assert(parser.front.data == "key");
1920 
1921 		parser.popFront();
1922 		assert(!parser.empty);
1923 		assert(parser.front.data == "\nvalue\n");
1924 	}
1925 
1926 	{
1927 		parser.popFront();
1928 		assert(!parser.empty);
1929 		assert(parser.front.data == "key");
1930 
1931 		parser.popFront();
1932 		assert(!parser.empty);
1933 		assert(parser.front.data == "foo\t bar");
1934 	}
1935 
1936 	{
1937 		parser.popFront();
1938 		assert(!parser.empty);
1939 		assert(parser.front.data == "key\0key");
1940 
1941 		parser.popFront();
1942 		assert(!parser.empty);
1943 		assert(parser.front.data == "value");
1944 	}
1945 
1946 	{
1947 		parser.popFront();
1948 		assert(!parser.empty);
1949 		assert(parser.front.data == "key =");
1950 
1951 		parser.popFront();
1952 		assert(!parser.empty);
1953 		assert(parser.front.data == "value");
1954 	}
1955 
1956 	parser.popFront();
1957 	assert(parser.empty);
1958 }
1959 
1960 @safe unittest {
1961 	static immutable string rawIni = `
1962 key = \nvalue\n
1963 key = foo\t bar
1964 key\0key = value
1965 key \= = value
1966 `;
1967 	enum dialect = Dialect.escapeSequences;
1968 	auto parser = makeIniFilteredParser!dialect(rawIni);
1969 
1970 	{
1971 		assert(!parser.empty);
1972 		assert(parser.front.data == "key");
1973 
1974 		parser.popFront();
1975 		assert(!parser.empty);
1976 		assert(parser.front.data == "\nvalue\n");
1977 	}
1978 
1979 	{
1980 		parser.popFront();
1981 		assert(!parser.empty);
1982 		assert(parser.front.data == "key");
1983 
1984 		parser.popFront();
1985 		assert(!parser.empty);
1986 		assert(parser.front.data == "foo\t bar");
1987 	}
1988 
1989 	{
1990 		parser.popFront();
1991 		assert(!parser.empty);
1992 		assert(parser.front.data == "key\0key");
1993 
1994 		parser.popFront();
1995 		assert(!parser.empty);
1996 		assert(parser.front.data == "value");
1997 	}
1998 
1999 	{
2000 		parser.popFront();
2001 		assert(!parser.empty);
2002 		assert(parser.front.data == "key =");
2003 
2004 		parser.popFront();
2005 		assert(!parser.empty);
2006 		assert(parser.front.data == "value");
2007 	}
2008 
2009 	parser.popFront();
2010 	assert(parser.empty);
2011 }
2012 
2013 @safe unittest {
2014 	char[] rawIni = "key = val\\\nue\nkey \\\n= \\\nvalue \\\rvalu\\\r\ne\n".dup;
2015 	enum dialect = Dialect.lineFolding;
2016 	auto parser = makeIniFilteredParser!dialect(rawIni);
2017 
2018 	{
2019 		assert(!parser.empty);
2020 		assert(parser.front.data == "key");
2021 
2022 		parser.popFront();
2023 		assert(!parser.empty);
2024 		assert(parser.front.data == "value");
2025 	}
2026 
2027 	{
2028 		parser.popFront();
2029 		assert(!parser.empty);
2030 		assert(parser.front.data == "key");
2031 
2032 		parser.popFront();
2033 		assert(!parser.empty);
2034 		assert(parser.front.data == "value value");
2035 	}
2036 
2037 	parser.popFront();
2038 	assert(parser.empty);
2039 }
2040 
2041 @safe unittest {
2042 	static immutable string rawIni = "key = val\\\nue\nkey \\\n= \\\nvalue \\\rvalu\\\r\ne\n";
2043 	enum dialect = Dialect.lineFolding;
2044 	auto parser = makeIniFilteredParser!dialect(rawIni);
2045 
2046 	{
2047 		assert(!parser.empty);
2048 		assert(parser.front.data == "key");
2049 
2050 		parser.popFront();
2051 		assert(!parser.empty);
2052 		assert(parser.front.data == "value");
2053 	}
2054 
2055 	{
2056 		parser.popFront();
2057 		assert(!parser.empty);
2058 		assert(parser.front.data == "key");
2059 
2060 		parser.popFront();
2061 		assert(!parser.empty);
2062 		assert(parser.front.data == "value value");
2063 	}
2064 
2065 	parser.popFront();
2066 	assert(parser.empty);
2067 }
2068 
2069 /++
2070 	Convenience function to create a low-level parser
2071 
2072 	$(TIP
2073 		Unlike with the constructor of [IniParser],
2074 		the compiler is able to infer the `string` template parameter.
2075 	)
2076 
2077 	See_also:
2078 		[makeIniFilteredParser]
2079  +/
2080 IniParser!(dialect, string) makeIniParser(
2081 	IniDialect dialect = IniDialect.defaults,
2082 	string,
2083 )(
2084 	string rawIni,
2085 ) @safe pure nothrow if (isCompatibleString!string) {
2086 	return IniParser!(dialect, string)(rawIni);
2087 }
2088 
2089 ///
2090 @safe @nogc unittest {
2091 	string regular;
2092 	auto parser1 = makeIniParser(regular);
2093 	assert(parser1.empty); // exclude from docs
2094 
2095 	char[] mutable;
2096 	auto parser2 = makeIniParser(mutable);
2097 	assert(parser2.empty); // exclude from docs
2098 
2099 	const(char)[] constChars;
2100 	auto parser3 = makeIniParser(constChars);
2101 	assert(parser3.empty); // exclude from docs
2102 
2103 	assert(!parser1.isDestructive); // exclude from docs
2104 	assert(!parser2.isDestructive); // exclude from docs
2105 	assert(!parser3.isDestructive); // exclude from docs
2106 }
2107 
2108 @safe unittest {
2109 	char[] mutableInput;
2110 	enum dialect = Dialect.concatSubstrings;
2111 
2112 	auto parser1 = makeIniParser!(dialect, const(char)[])(mutableInput);
2113 	auto parser2 = (() @nogc => makeIniParser!(dialect)(mutableInput))();
2114 
2115 	assert(!parser1.isDestructive);
2116 	assert(parser2.isDestructive);
2117 }
2118 
2119 /++
2120 	Convenience function to create a low-level filtered parser
2121 
2122 	$(TIP
2123 		Unlike with the constructor of [IniFilteredParser],
2124 		the compiler is able to infer the `string` template parameter.
2125 	)
2126 
2127 	See_also:
2128 		[makeIniParser]
2129  +/
2130 IniFilteredParser!(dialect, string) makeIniFilteredParser(
2131 	IniDialect dialect = IniDialect.defaults,
2132 	string,
2133 )(
2134 	string rawIni,
2135 ) @safe pure nothrow if (isCompatibleString!string) {
2136 	return IniFilteredParser!(dialect, string)(rawIni);
2137 }
2138 
2139 ///
2140 @safe @nogc unittest {
2141 	string regular;
2142 	auto parser1 = makeIniFilteredParser(regular);
2143 	assert(parser1.empty); // exclude from docs
2144 
2145 	char[] mutable;
2146 	auto parser2 = makeIniFilteredParser(mutable);
2147 	assert(parser2.empty); // exclude from docs
2148 
2149 	const(char)[] constChars;
2150 	auto parser3 = makeIniFilteredParser(constChars);
2151 	assert(parser3.empty); // exclude from docs
2152 }
2153 
2154 // undocumented
2155 debug {
2156 	void writelnTokens(IniDialect dialect, string)(IniParser!(dialect, string) parser) @safe {
2157 		import std.stdio : writeln;
2158 
2159 		foreach (token; parser) {
2160 			writeln(token);
2161 		}
2162 	}
2163 
2164 	void writelnTokens(IniDialect dialect, string)(IniFilteredParser!(dialect, string) parser) @safe {
2165 		import std.stdio : writeln;
2166 
2167 		foreach (token; parser) {
2168 			writeln(token);
2169 		}
2170 	}
2171 }
2172 
2173 /++
2174 	Data entry of an INI document
2175  +/
2176 struct IniKeyValuePair(string) if (isCompatibleString!string) {
2177 	///
2178 	string key;
2179 
2180 	///
2181 	string value;
2182 }
2183 
2184 /++
2185 	Section of an INI document
2186 
2187 	$(NOTE
2188 		Data entries from the document’s root – i.e. those with no designated section –
2189 		are stored in a section with its `name` set to `null`.
2190 	)
2191  +/
2192 struct IniSection(string) if (isCompatibleString!string) {
2193 	///
2194 	alias KeyValuePair = IniKeyValuePair!string;
2195 
2196 	/++
2197 		Name of the section
2198 
2199 		Also known as “key”.
2200 	 +/
2201 	string name;
2202 
2203 	/++
2204 		Data entries of the section
2205 	 +/
2206 	KeyValuePair[] items;
2207 }
2208 
2209 /++
2210 	DOM representation of an INI document
2211  +/
2212 struct IniDocument(string) if (isCompatibleString!string) {
2213 	///
2214 	alias Section = IniSection!string;
2215 
2216 	/++
2217 		Sections of the document
2218 
2219 		$(NOTE
2220 			Data entries from the document’s root – i.e. those with no designated section –
2221 			are stored in a section with its `name` set to `null`.
2222 
2223 			If there are no named sections in a document, there will be only a single section with no name (`null`).
2224 		)
2225 	 +/
2226 	Section[] sections;
2227 }
2228 
2229 /++
2230 	Parses an INI string into a document ("DOM").
2231 
2232 	See_also:
2233 		$(LIST
2234 			* [parseIniAA]
2235 			* [parseIniMergedAA]
2236 		)
2237  +/
2238 IniDocument!string parseIniDocument(IniDialect dialect = IniDialect.defaults, string)(string rawIni) @safe pure nothrow
2239 if (isCompatibleString!string) {
2240 	alias Document = IniDocument!string;
2241 	alias Section = IniSection!string;
2242 	alias KeyValuePair = IniKeyValuePair!string;
2243 
2244 	auto parser = IniParser!(dialect, string)(rawIni);
2245 
2246 	auto document = Document(null);
2247 	auto section = Section(null, null);
2248 	auto kvp = KeyValuePair(null, null);
2249 
2250 	void commitKeyValuePair(string nextKey = null) {
2251 		if (kvp.key !is null) {
2252 			section.items ~= kvp;
2253 		}
2254 		kvp = KeyValuePair(nextKey, null);
2255 	}
2256 
2257 	void commitSection(string nextSectionName) {
2258 		commitKeyValuePair(null);
2259 
2260 		const isNamelessAndEmpty = (
2261 			(section.name is null)
2262 				&& (section.items.length == 0)
2263 		);
2264 
2265 		if (!isNamelessAndEmpty) {
2266 			document.sections ~= section;
2267 		}
2268 
2269 		if (nextSectionName !is null) {
2270 			section = Section(nextSectionName, null);
2271 		}
2272 	}
2273 
2274 	while (!parser.skipIrrelevant()) {
2275 		switch (parser.front.type) with (TokenType) {
2276 
2277 		case key:
2278 			commitKeyValuePair(parser.front.data);
2279 			break;
2280 
2281 		case value:
2282 			kvp.value = parser.front.data;
2283 			break;
2284 
2285 		case sectionHeader:
2286 			commitSection(parser.front.data);
2287 			break;
2288 
2289 		default:
2290 			assert(false, "Unexpected parsing error."); // TODO
2291 		}
2292 
2293 		parser.popFront();
2294 	}
2295 
2296 	commitSection(null);
2297 
2298 	return document;
2299 }
2300 
2301 ///
2302 @safe unittest {
2303 	// INI document (demo data)
2304 	static immutable string iniString = `; This is a comment.
2305 
2306 Oachkatzlschwoaf = Seriously, try pronouncing this :P
2307 
2308 [Section #1]
2309 foo = bar
2310 d = rocks
2311 
2312 ; Another comment
2313 
2314 [Section No.2]
2315 name    = Walter Bright
2316 company = "Digital Mars"
2317 `;
2318 
2319 	// Parse the document.
2320 	auto doc = parseIniDocument(iniString);
2321 
2322 	version (none) // exclude from docs
2323 	// …is equivalent to:
2324 	auto doc = parseIniDocument!(IniDialect.defaults)(iniString);
2325 
2326 	assert(doc.sections.length == 3);
2327 
2328 	// "Root" section (no name):
2329 	assert(doc.sections[0].name is null);
2330 	assert(doc.sections[0].items == [
2331 		IniKeyValuePair!string("Oachkatzlschwoaf", "Seriously, try pronouncing this :P"),
2332 	]);
2333 
2334 	// A section with a name:
2335 	assert(doc.sections[1].name == "Section #1");
2336 	assert(doc.sections[1].items.length == 2);
2337 	assert(doc.sections[1].items[0] == IniKeyValuePair!string("foo", "bar"));
2338 	assert(doc.sections[1].items[1] == IniKeyValuePair!string("d", "rocks"));
2339 
2340 	// Another section:
2341 	assert(doc.sections[2].name == "Section No.2");
2342 	assert(doc.sections[2].items == [
2343 		IniKeyValuePair!string("name", "Walter Bright"),
2344 		IniKeyValuePair!string("company", "Digital Mars"),
2345 	]);
2346 }
2347 
2348 @safe unittest {
2349 	auto doc = parseIniDocument("");
2350 	assert(doc.sections == []);
2351 
2352 	doc = parseIniDocument(";Comment\n;Comment2\n");
2353 	assert(doc.sections == []);
2354 }
2355 
2356 @safe unittest {
2357 	char[] mutable = ['f', 'o', 'o', '=', 'b', 'a', 'r', '\n'];
2358 
2359 	auto doc = parseIniDocument(mutable);
2360 	assert(doc.sections[0].items[0].key == "foo");
2361 	assert(doc.sections[0].items[0].value == "bar");
2362 
2363 	// is mutable
2364 	static assert(is(typeof(doc.sections[0].items[0].value) == char[]));
2365 }
2366 
2367 @safe unittest {
2368 	static immutable demoData = `
2369 0 = a 'b'
2370 1 = a "b"
2371 2 = 'a' b
2372 3 = "a" b
2373 `;
2374 
2375 	enum dialect = (Dialect.concatSubstrings | Dialect.quotedStrings | Dialect.singleQuoteQuotedStrings);
2376 	auto doc = parseIniDocument!dialect(demoData);
2377 	assert(doc.sections[0].items[0].value == "a b");
2378 	assert(doc.sections[0].items[1].value == "ab");
2379 	assert(doc.sections[0].items[2].value == "a b");
2380 	assert(doc.sections[0].items[3].value == "ab");
2381 }
2382 
2383 /++
2384 	Parses an INI string into an associate array.
2385 
2386 	$(LIST
2387 		* Duplicate keys cause values to get overwritten.
2388 		* Sections with the same name are merged.
2389 	)
2390 
2391 	See_also:
2392 		$(LIST
2393 			* [parseIniMergedAA]
2394 			* [parseIniDocument]
2395 		)
2396  +/
2397 string[immutable(char)[]][immutable(char)[]] parseIniAA(
2398 	IniDialect dialect = IniDialect.defaults,
2399 	string,
2400 )(
2401 	string rawIni,
2402 ) @safe pure nothrow {
2403 	static if (is(string == immutable(char)[])) {
2404 		immutable(char)[] toString(string key) => key;
2405 	} else {
2406 		immutable(char)[] toString(string key) => key.idup;
2407 	}
2408 
2409 	auto parser = IniParser!(dialect, string)(rawIni);
2410 
2411 	string[immutable(char)[]][immutable(char)[]] document;
2412 	string[immutable(char)[]] section;
2413 
2414 	string sectionName = null;
2415 	string keyName = null;
2416 	string value = null;
2417 
2418 	void commitKeyValuePair(string nextKey) {
2419 		if (keyName !is null) {
2420 			section[toString(keyName)] = value;
2421 		}
2422 
2423 		keyName = nextKey;
2424 		value = null;
2425 	}
2426 
2427 	void setValue(string nextValue) {
2428 		value = nextValue;
2429 	}
2430 
2431 	void commitSection(string nextSection) {
2432 		commitKeyValuePair(null);
2433 		if ((sectionName !is null) || (section.length > 0)) {
2434 			document[toString(sectionName)] = section;
2435 			section = null;
2436 		}
2437 
2438 		if (nextSection !is null) {
2439 			auto existingSection = nextSection in document;
2440 			if (existingSection !is null) {
2441 				section = *existingSection;
2442 			}
2443 
2444 			sectionName = nextSection;
2445 		}
2446 	}
2447 
2448 	while (!parser.skipIrrelevant()) {
2449 		switch (parser.front.type) with (TokenType) {
2450 
2451 		case key:
2452 			commitKeyValuePair(parser.front.data);
2453 			break;
2454 
2455 		case value:
2456 			setValue(parser.front.data);
2457 			break;
2458 
2459 		case sectionHeader:
2460 			commitSection(parser.front.data);
2461 			break;
2462 
2463 		default:
2464 			assert(false, "Unexpected parsing error."); // TODO
2465 		}
2466 
2467 		parser.popFront();
2468 	}
2469 
2470 	commitSection(null);
2471 
2472 	return document;
2473 }
2474 
2475 ///
2476 @safe unittest {
2477 	// INI document
2478 	static immutable string demoData = `; This is a comment.
2479 
2480 Oachkatzlschwoaf = Seriously, try pronouncing this :P
2481 
2482 [Section #1]
2483 foo = bar
2484 d = rocks
2485 
2486 ; Another comment
2487 
2488 [Section No.2]
2489 name    = Walter Bright
2490 company = "Digital Mars"
2491 website = <https://digitalmars.com/>
2492 ;email  = "noreply@example.org"
2493 `;
2494 
2495 	// Parse the document into an associative array.
2496 	auto aa = parseIniAA(demoData);
2497 
2498 	assert(aa.length == 3);
2499 
2500 	assert(aa[null].length == 1);
2501 	assert(aa[null]["Oachkatzlschwoaf"] == "Seriously, try pronouncing this :P");
2502 
2503 	assert(aa["Section #1"].length == 2);
2504 	assert(aa["Section #1"]["foo"] == "bar");
2505 	assert(aa["Section #1"]["d"] == "rocks");
2506 
2507 	string[string] section2 = aa["Section No.2"];
2508 	assert(section2.length == 3);
2509 	assert(section2["name"] == "Walter Bright");
2510 	assert(section2["company"] == "Digital Mars");
2511 	assert(section2["website"] == "<https://digitalmars.com/>");
2512 
2513 	// "email" is commented out
2514 	assert(!("email" in section2));
2515 }
2516 
2517 @safe unittest {
2518 	static immutable demoData = `[1]
2519 key = "value1" "value2"
2520 [2]
2521 0 = a b
2522 1 = 'a' b
2523 2 = a 'b'
2524 3 = a "b"
2525 4 = "a" 'b'
2526 5 = 'a' "b"
2527 6 = "a" "b"
2528 7 = 'a' 'b'
2529 8 = 'a' "b" 'c'
2530 `;
2531 
2532 	enum dialect = (Dialect.concatSubstrings | Dialect.quotedStrings | Dialect.singleQuoteQuotedStrings);
2533 	auto aa = parseIniAA!(dialect, char[])(demoData.dup);
2534 
2535 	assert(aa.length == 2);
2536 	assert(!(null in aa));
2537 	assert("1" in aa);
2538 	assert("2" in aa);
2539 	assert(aa["1"]["key"] == "value1value2");
2540 	assert(aa["2"]["0"] == "a b");
2541 	assert(aa["2"]["1"] == "a b");
2542 	assert(aa["2"]["2"] == "a b");
2543 	assert(aa["2"]["3"] == "ab");
2544 	assert(aa["2"]["4"] == "ab");
2545 	assert(aa["2"]["5"] == "ab");
2546 	assert(aa["2"]["6"] == "ab");
2547 	assert(aa["2"]["7"] == "a b");
2548 	assert(aa["2"]["8"] == "abc");
2549 }
2550 
2551 @safe unittest {
2552 	static immutable string demoData = `[1]
2553 key = "value1" "value2"
2554 [2]
2555 0 = a b
2556 1 = 'a' b
2557 2 = a 'b'
2558 3 = a "b"
2559 4 = "a" 'b'
2560 5 = 'a' "b"
2561 6 = "a" "b"
2562 7 = 'a' 'b'
2563 8 = 'a' "b" 'c'
2564 `;
2565 
2566 	enum dialect = (Dialect.concatSubstrings | Dialect.quotedStrings | Dialect.singleQuoteQuotedStrings);
2567 	auto aa = parseIniAA!dialect(demoData);
2568 
2569 	assert(aa.length == 2);
2570 	assert(!(null in aa));
2571 	assert("1" in aa);
2572 	assert("2" in aa);
2573 	assert(aa["1"]["key"] == "value1value2");
2574 	assert(aa["2"]["0"] == "a b");
2575 	assert(aa["2"]["1"] == "a b");
2576 	assert(aa["2"]["2"] == "a b");
2577 	assert(aa["2"]["3"] == "ab");
2578 	assert(aa["2"]["4"] == "ab");
2579 	assert(aa["2"]["5"] == "ab");
2580 	assert(aa["2"]["6"] == "ab");
2581 	assert(aa["2"]["7"] == "a b");
2582 	assert(aa["2"]["8"] == "abc");
2583 }
2584 
2585 @safe unittest {
2586 	static immutable string demoData = `
2587 0 = "a" b
2588 1 = "a" 'b'
2589 2 = a "b"
2590 3 = 'a' "b"
2591 `;
2592 
2593 	enum dialect = (Dialect.concatSubstrings | Dialect.singleQuoteQuotedStrings);
2594 	auto aa = parseIniAA!dialect(demoData);
2595 
2596 	assert(aa.length == 1);
2597 	assert(aa[null]["0"] == `"a" b`);
2598 	assert(aa[null]["1"] == `"a" b`);
2599 	assert(aa[null]["2"] == `a "b"`);
2600 	assert(aa[null]["3"] == `a "b"`);
2601 }
2602 
2603 @safe unittest {
2604 	static immutable const(char)[] demoData = `[1]
2605 key = original
2606 no2 = kept
2607 [2]
2608 key = original
2609 key = overwritten
2610 [1]
2611 key = merged and overwritten
2612 `;
2613 
2614 	enum dialect = Dialect.concatSubstrings;
2615 	auto aa = parseIniAA!dialect(demoData);
2616 
2617 	assert(aa.length == 2);
2618 	assert(!(null in aa));
2619 	assert("1" in aa);
2620 	assert("2" in aa);
2621 	assert(aa["1"]["key"] == "merged and overwritten");
2622 	assert(aa["1"]["no2"] == "kept");
2623 	assert(aa["2"]["key"] == "overwritten");
2624 }
2625 
2626 /++
2627 	Parses an INI string into a section-less associate array.
2628 	All sections are merged.
2629 
2630 	$(LIST
2631 		* Section names are discarded.
2632 		* Duplicate keys cause values to get overwritten.
2633 	)
2634 
2635 	See_also:
2636 		$(LIST
2637 			* [parseIniAA]
2638 			* [parseIniDocument]
2639 		)
2640  +/
2641 string[immutable(char)[]] parseIniMergedAA(
2642 	IniDialect dialect = IniDialect.defaults,
2643 	string,
2644 )(
2645 	string rawIni,
2646 ) @safe pure nothrow {
2647 	static if (is(string == immutable(char)[])) {
2648 		immutable(char)[] toString(string key) => key;
2649 	} else {
2650 		immutable(char)[] toString(string key) => key.idup;
2651 	}
2652 
2653 	auto parser = IniParser!(dialect, string)(rawIni);
2654 
2655 	string[immutable(char)[]] section;
2656 
2657 	string keyName = null;
2658 	string value = null;
2659 
2660 	void commitKeyValuePair(string nextKey) {
2661 		if (keyName !is null) {
2662 			section[toString(keyName)] = value;
2663 		}
2664 
2665 		keyName = nextKey;
2666 		value = null;
2667 	}
2668 
2669 	void setValue(string nextValue) {
2670 		value = nextValue;
2671 	}
2672 
2673 	while (!parser.skipIrrelevant()) {
2674 		switch (parser.front.type) with (TokenType) {
2675 
2676 		case key:
2677 			commitKeyValuePair(parser.front.data);
2678 			break;
2679 
2680 		case value:
2681 			setValue(parser.front.data);
2682 			break;
2683 
2684 		case sectionHeader:
2685 			// nothing to do
2686 			break;
2687 
2688 		default:
2689 			assert(false, "Unexpected parsing error."); // TODO
2690 		}
2691 
2692 		parser.popFront();
2693 	}
2694 
2695 	commitKeyValuePair(null);
2696 
2697 	return section;
2698 }
2699 
2700 ///
2701 @safe unittest {
2702 	static immutable demoData = `
2703 key0 = value0
2704 
2705 [1]
2706 key1 = value1
2707 key2 = other value
2708 
2709 [2]
2710 key1 = value2
2711 key3 = yet another value`;
2712 
2713 	// Parse INI file into an associative array with merged sections.
2714 	string[string] aa = parseIniMergedAA(demoData);
2715 
2716 	// As sections were merged, entries sharing the same key got overridden.
2717 	// Hence, there are only four entries left.
2718 	assert(aa.length == 4);
2719 
2720 	// The "key1" entry of the first section got overruled
2721 	// by the "key1" entry of the second section that came later.
2722 	assert(aa["key1"] == "value2");
2723 
2724 	// Entries with unique keys got through unaffected.
2725 	assert(aa["key0"] == "value0");
2726 	assert(aa["key2"] == "other value");
2727 	assert(aa["key3"] == "yet another value");
2728 }
2729 
2730 private void stringifyIniString(string, OutputRange)(string data, OutputRange output) {
2731 	if (data is null) {
2732 		output.put("\"\"");
2733 		return;
2734 	}
2735 
2736 	size_t nQuotes = 0;
2737 	size_t nSingleQuotes = 0;
2738 	bool hasLineBreaks = false;
2739 
2740 	foreach (const c; data) {
2741 		switch (c) {
2742 		default:
2743 			break;
2744 
2745 		case '"':
2746 			++nQuotes;
2747 			break;
2748 		case '\'':
2749 			++nSingleQuotes;
2750 			break;
2751 
2752 		case '\n':
2753 		case '\r':
2754 			hasLineBreaks = true;
2755 			break;
2756 		}
2757 	}
2758 
2759 	const hasQuotes = (nQuotes > 0);
2760 	const hasSingleQuotes = (nSingleQuotes > 0);
2761 
2762 	if (hasQuotes && !hasSingleQuotes) {
2763 		output.put("'");
2764 		output.put(data);
2765 		output.put("'");
2766 		return;
2767 	}
2768 
2769 	if (!hasQuotes && hasSingleQuotes) {
2770 		output.put("\"");
2771 		output.put(data);
2772 		output.put("\"");
2773 		return;
2774 	}
2775 
2776 	if (hasQuotes && hasSingleQuotes) {
2777 		if (nQuotes <= nSingleQuotes) {
2778 			output.put("\"");
2779 
2780 			foreach (const c; StringSliceRange(data)) {
2781 				if (c == "\"") {
2782 					output.put("\" '\"' \"");
2783 					continue;
2784 				}
2785 
2786 				output.put(c);
2787 			}
2788 
2789 			output.put("\"");
2790 			return;
2791 		}
2792 
2793 		if ( /*nQuotes > nSingleQuotes*/ true) {
2794 			output.put("'");
2795 
2796 			foreach (const c; StringSliceRange(data)) {
2797 				if (c == "'") {
2798 					output.put("' \"'\" '");
2799 					continue;
2800 				}
2801 
2802 				output.put(c);
2803 			}
2804 
2805 			output.put("'");
2806 			return;
2807 		}
2808 	}
2809 
2810 	if ( /*!hasQuotes && !hasSingleQuotes*/ true) {
2811 		if (hasLineBreaks) {
2812 			output.put("\"");
2813 		}
2814 
2815 		output.put(data);
2816 
2817 		if (hasLineBreaks) {
2818 			output.put("\"");
2819 		}
2820 	}
2821 }
2822 
2823 /++
2824 	Serializes a `key` + `value` pair to a string in INI format.
2825  +/
2826 void stringifyIni(StringKey, StringValue, OutputRange)(StringKey key, StringValue value, OutputRange output)
2827 		if (isCompatibleString!StringKey && isCompatibleString!StringValue) {
2828 	stringifyIniString(key, output);
2829 	output.put(" = ");
2830 	stringifyIniString(value, output);
2831 	output.put("\n");
2832 }
2833 
2834 /// ditto
2835 string stringifyIni(StringKey, StringValue)(StringKey key, StringValue value)
2836 		if (isCompatibleString!StringKey && isCompatibleString!StringValue) {
2837 	import std.array : appender;
2838 
2839 	auto output = appender!string();
2840 	stringifyIni(key, value, output);
2841 	return output[];
2842 }
2843 
2844 /++
2845 	Serializes an [IniKeyValuePair] to a string in INI format.
2846  +/
2847 void stringifyIni(string, OutputRange)(const IniKeyValuePair!string kvp, OutputRange output) {
2848 	return stringifyIni(kvp.key, kvp.value, output);
2849 }
2850 
2851 /// ditto
2852 string stringifyIni(string)(const IniKeyValuePair!string kvp) {
2853 	import std.array : appender;
2854 
2855 	auto output = appender!string();
2856 	stringifyIni(kvp, output);
2857 	return output[];
2858 }
2859 
2860 private void stringifyIniSectionHeader(string, OutputRange)(string sectionName, OutputRange output) {
2861 	if (sectionName !is null) {
2862 		output.put("[");
2863 		stringifyIniString(sectionName, output);
2864 		output.put("]\n");
2865 	}
2866 }
2867 
2868 /++
2869 	Serializes an [IniSection] to a string in INI format.
2870  +/
2871 void stringifyIni(string, OutputRange)(const IniSection!string section, OutputRange output) {
2872 	stringifyIniSectionHeader(section.name, output);
2873 	foreach (const item; section.items) {
2874 		stringifyIni(item, output);
2875 	}
2876 }
2877 
2878 /// ditto
2879 string stringifyIni(string)(const IniSection!string section) {
2880 	import std.array : appender;
2881 
2882 	auto output = appender!string();
2883 	stringifyIni(section, output);
2884 	return output[];
2885 }
2886 
2887 /++
2888 	Serializes an [IniDocument] to a string in INI format.
2889  +/
2890 void stringifyIni(string, OutputRange)(IniDocument!string document, OutputRange output) {
2891 	bool anySectionsWritten = false;
2892 
2893 	foreach (const section; document.sections) {
2894 		if (section.name is null) {
2895 			if (anySectionsWritten) {
2896 				output.put("\n");
2897 			}
2898 
2899 			stringifyIni(section, output);
2900 
2901 			if (section.items.length > 0) {
2902 				anySectionsWritten = true;
2903 			}
2904 		}
2905 	}
2906 
2907 	foreach (const section; document.sections) {
2908 		if (section.name is null) {
2909 			continue;
2910 		}
2911 
2912 		if (!anySectionsWritten) {
2913 			anySectionsWritten = true;
2914 		} else {
2915 			output.put("\n");
2916 		}
2917 
2918 		stringifyIni(section, output);
2919 	}
2920 }
2921 
2922 /// ditto
2923 string stringifyIni(string)(IniDocument!string document) {
2924 	import std.array : appender;
2925 
2926 	auto output = appender!string();
2927 	stringifyIni(document, output);
2928 	return output[];
2929 }
2930 
2931 ///
2932 @safe unittest {
2933 	auto doc = IniDocument!string([
2934 		IniSection!string(null, [
2935 			IniKeyValuePair!string("key", "value"),
2936 		]),
2937 		IniSection!string("Section 1", [
2938 			IniKeyValuePair!string("key1", "value1"),
2939 			IniKeyValuePair!string("key2", "foo'bar"),
2940 		]),
2941 	]);
2942 
2943 	// Serialize
2944 	string ini = stringifyIni(doc);
2945 
2946 	static immutable expected =
2947 		"key = value\n"
2948 		~ "\n"
2949 		~ "[Section 1]\n"
2950 		~ "key1 = value1\n"
2951 		~ "key2 = \"foo'bar\"\n";
2952 	assert(ini == expected);
2953 }
2954 
2955 @safe unittest {
2956 	auto doc = IniDocument!string([
2957 		IniSection!string("Oachkatzlschwoaf", [
2958 			IniKeyValuePair!string("key1", "value1"),
2959 			IniKeyValuePair!string("key2", "value2"),
2960 			IniKeyValuePair!string("key3", "foo bar"),
2961 		]),
2962 		IniSection!string(null, [
2963 			IniKeyValuePair!string("key", "value"),
2964 		]),
2965 		IniSection!string("Kaiserschmarrn", [
2966 			IniKeyValuePair!string("1", "value\n1"),
2967 			IniKeyValuePair!string("2", "\"value\t2"),
2968 			IniKeyValuePair!string("3", "\"foo'bar\""),
2969 			IniKeyValuePair!string("4", "'foo\"bar'"),
2970 		]),
2971 	]);
2972 
2973 	string ini = stringifyIni(doc);
2974 
2975 	static immutable expected = "key = value\n"
2976 		~ "\n"
2977 		~ "[Oachkatzlschwoaf]\n"
2978 		~ "key1 = value1\n"
2979 		~ "key2 = value2\n"
2980 		~ "key3 = foo bar\n"
2981 		~ "\n"
2982 		~ "[Kaiserschmarrn]\n"
2983 		~ "1 = \"value\n1\"\n"
2984 		~ "2 = '\"value\t2'\n"
2985 		~ "3 = '\"foo' \"'\" 'bar\"'\n"
2986 		~ "4 = \"'foo\" '\"' \"bar'\"\n";
2987 	assert(ini == expected);
2988 }
2989 
2990 /++
2991 	Serializes an AA to a string in INI format.
2992  +/
2993 void stringifyIni(
2994 	StringKey,
2995 	StringValue,
2996 	OutputRange,
2997 )(
2998 	const StringValue[StringKey] sectionItems,
2999 	OutputRange output,
3000 ) if (isCompatibleString!StringKey && isCompatibleString!StringValue) {
3001 	foreach (key, value; sectionItems) {
3002 		stringifyIni(key, value, output);
3003 	}
3004 }
3005 
3006 /// ditto
3007 string stringifyIni(
3008 	StringKey,
3009 	StringValue,
3010 )(
3011 	const StringValue[StringKey] sectionItems
3012 ) if (isCompatibleString!StringKey && isCompatibleString!StringValue) {
3013 	import std.array : appender;
3014 
3015 	auto output = appender!string();
3016 	stringifyIni(sectionItems, output);
3017 	return output[];
3018 }
3019 
3020 ///
3021 @safe unittest {
3022 	string[string] doc;
3023 	doc["1"] = "value1";
3024 	doc["2"] = "foo'bar";
3025 
3026 	// Serialize AA to INI
3027 	string ini = stringifyIni(doc);
3028 
3029 	// dfmt off
3030 	static immutable expectedEither = "1 = value1\n"      ~ "2 = \"foo'bar\"\n"; // exclude from docs
3031 	static immutable expectedOr     = "2 = \"foo'bar\"\n" ~ "1 = value1\n"     ; // exclude from docs
3032 	// dfmt on
3033 
3034 	assert(ini == expectedEither || ini == expectedOr); // exclude from docs
3035 }
3036 
3037 /++
3038 	Serializes a nested AA to a string in INI format.
3039  +/
3040 void stringifyIni(
3041 	StringSection,
3042 	StringKey,
3043 	StringValue,
3044 	OutputRange,
3045 )(
3046 	const StringValue[StringKey][StringSection] document,
3047 	OutputRange output,
3048 ) if (isCompatibleString!StringSection && isCompatibleString!StringKey && isCompatibleString!StringValue) {
3049 	bool anySectionsWritten = false;
3050 
3051 	const rootSection = null in document;
3052 	if (rootSection !is null) {
3053 		stringifyIni(*rootSection, output);
3054 		anySectionsWritten = true;
3055 	}
3056 
3057 	foreach (sectionName, items; document) {
3058 		if (sectionName is null) {
3059 			continue;
3060 		}
3061 
3062 		if (!anySectionsWritten) {
3063 			anySectionsWritten = true;
3064 		} else {
3065 			output.put("\n");
3066 		}
3067 
3068 		stringifyIniSectionHeader(sectionName, output);
3069 		foreach (key, value; items) {
3070 			stringifyIni(key, value, output);
3071 		}
3072 	}
3073 }
3074 
3075 /// ditto
3076 string stringifyIni(
3077 	StringSection,
3078 	StringKey,
3079 	StringValue,
3080 )(
3081 	const StringValue[StringKey][StringSection] document,
3082 ) if (isCompatibleString!StringSection && isCompatibleString!StringKey && isCompatibleString!StringValue) {
3083 	import std.array : appender;
3084 
3085 	auto output = appender!string();
3086 	stringifyIni(document, output);
3087 	return output[];
3088 }
3089 
3090 ///
3091 @safe unittest {
3092 	string[string][string] doc;
3093 
3094 	doc[null]["key"] = "value";
3095 	doc[null]["foo"] = "bar";
3096 
3097 	doc["Section 1"]["firstname"] = "Walter";
3098 	doc["Section 1"]["lastname"] = "Bright";
3099 	doc["Section 1"]["language"] = "'D'";
3100 
3101 	doc["Section 2"]["Oachkatzl"] = "Schwoaf";
3102 
3103 	// Serialize AA to INI
3104 	string ini = stringifyIni(doc);
3105 
3106 	import std.string : indexOf, startsWith; // exclude from docs
3107 
3108 	assert(ini.startsWith("key = value\n") || ini.startsWith("foo = bar\n")); // exclude from docs
3109 	assert(ini.indexOf("\n[Section 1]\n") > 0); // exclude from docs
3110 	assert(ini.indexOf("\nfirstname = Walter\n") > 0); // exclude from docs
3111 	assert(ini.indexOf("\nlastname = Bright\n") > 0); // exclude from docs
3112 	assert(ini.indexOf("\nlanguage = \"'D'\"\n") > 0); // exclude from docs
3113 	assert(ini.indexOf("\n[Section 2]\n") > 0); // exclude from docs
3114 	assert(ini.indexOf("\nOachkatzl = Schwoaf\n") > 0); // exclude from docs
3115 }
3116 
3117 @safe unittest {
3118 	string[string][string] doc;
3119 	doc[null]["key"] = "value";
3120 	doc["S1"]["1"] = "value1";
3121 	doc["S1"]["2"] = "value2";
3122 	doc["S2"]["x"] = "foo'bar";
3123 	doc["S2"][null] = "bamboozled";
3124 
3125 	string ini = stringifyIni(doc);
3126 
3127 	import std.string : indexOf, startsWith;
3128 
3129 	assert(ini.startsWith("key = value\n"));
3130 	assert(ini.indexOf("\n[S1]\n") > 0);
3131 	assert(ini.indexOf("\n1 = value1\n") > 0);
3132 	assert(ini.indexOf("\n2 = value2\n") > 0);
3133 	assert(ini.indexOf("\n[S2]\n") > 0);
3134 	assert(ini.indexOf("\nx = \"foo'bar\"\n") > 0);
3135 	assert(ini.indexOf("\n\"\" = bamboozled\n") > 0);
3136 }
3137 
3138 @safe unittest {
3139 	const section = IniSection!string("Section Name", [
3140 		IniKeyValuePair!string("monkyyy", "business"),
3141 		IniKeyValuePair!string("Oachkatzl", "Schwoaf"),
3142 	]);
3143 
3144 	static immutable expected = "[Section Name]\n"
3145 		~ "monkyyy = business\n"
3146 		~ "Oachkatzl = Schwoaf\n";
3147 
3148 	assert(stringifyIni(section) == expected);
3149 }
3150 
3151 @safe unittest {
3152 	const kvp = IniKeyValuePair!string("Key", "Value");
3153 	assert(stringifyIni(kvp) == "Key = Value\n");
3154 }
3155 
3156 @safe unittest {
3157 	assert(stringifyIni("monkyyy", "business lol") == "monkyyy = business lol\n");
3158 }