1 /**
2 	This module manages a config file format in the form of key=value. Much like an ini file but simpler.
3 
4 	Author: Paul Crane
5 */
6 
7 module variantconfig;
8 
9 import std.conv : to;
10 import std.string;
11 import std.stdio : File, writeln;
12 import std.file : exists, readText;
13 import std.algorithm;
14 import std.range : take;
15 import std.array : empty, array;
16 import std.typecons : tuple;
17 import std.variant;
18 
19 import typeutils;
20 
21 private enum DEFAULT_GROUP_NAME = null;
22 private enum DEFAULT_CONFIG_FILE_NAME = "app.config";
23 
24 private struct KeyValueData
25 {
26 	string key;
27 	Variant value;
28 	string group;
29 	string comment;
30 }
31 
32 /**
33 	Handles the processing of config files.
34 */
35 struct VariantConfig
36 {
37 private:
38 
39 	/**
40 		Processes the text found in config file into an array of KeyValueData structures.
41 
42 		Params:
43 			text = The text to be processed.
44 	*/
45 	bool processText(const string text) @trusted
46 	{
47 		auto lines = text.lineSplitter();
48 		string currentGroupName = DEFAULT_GROUP_NAME;
49 		string currentComment;
50 
51 		foreach(line; lines)
52 		{
53 			line = strip(line);
54 
55 			if(line.empty)
56 			{
57 				continue;
58 			}
59 			else if(line.startsWith("#"))
60 			{
61 				currentComment = line[1..$];
62 			}
63 			else if(line.startsWith("["))
64 			{
65 				if(line.endsWith("]"))
66 				{
67 					immutable string groupName = line[1..$-1];
68 					currentGroupName = groupName;
69 				}
70 				else
71 				{
72 					return false; // Error incomplete group.
73 				}
74 			}
75 			else
76 			{
77 				auto groupAndKey = line.findSplit("=");
78 				immutable auto key = groupAndKey[0].stripRight();
79 				immutable auto value = groupAndKey[2].stripLeft();
80 
81 				if(groupAndKey[1].length)
82 				{
83 					KeyValueData data;
84 
85 					data.key = key;
86 					data.group = currentGroupName;
87 
88 					if(value.isInteger)
89 					{
90 						data.value = to!long(value);
91 					}
92 					else if(value.isDecimal)
93 					{
94 						data.value = to!double(value);
95 					}
96 					else if(isBoolean(value, AllowNumericBooleanValues.no))
97 					{
98 						data.value = to!bool(value);
99 					}
100 					else
101 					{
102 						data.value = value;
103 					}
104 
105 					if(currentComment != "")
106 					{
107 						data.comment = currentComment;
108 						currentComment = string.init;
109 					}
110 
111 					values_ ~= data;
112 				}
113 				else
114 				{
115 					return false; // Error line doesn't contain an = sign.
116 				}
117 			}
118 		}
119 
120 		return true;
121 	}
122 
123 	/**
124 		Determines if the group string is in the form of group.key.
125 
126 		Params:
127 			value = The string to test.
128 
129 		Returns:
130 			true if the string is in the group.key form false otherwise.
131 	*/
132 	bool isGroupString(const string value) pure @safe
133 	{
134 		if(value.indexOf(".") == -1)
135 		{
136 			return false;
137 		}
138 
139 		return true;
140 	}
141 
142 	/**
143 		Retrieves the group and key from a string in the form of group.key.
144 
145 		Params:
146 			value = The string to process.
147 
148 		Returns:
149 			A tuple containing the group and key.
150 	*/
151 	auto getGroupAndKeyFromString(const string value) pure @safe
152 	{
153 		auto groupAndKey = value.findSplit(".");
154 		immutable auto group = groupAndKey[0].strip();
155 		immutable auto key = groupAndKey[2].strip();
156 
157 		return tuple!("group", "key")(group, key);
158 	}
159 
160 public:
161 	/**
162 		Saves config values to the config file used when loading(loadFile).
163 	*/
164 	void save() @trusted
165 	{
166 		save(saveToFileName_);
167 	}
168 
169 	/**
170 		Saves config values to the config file.
171 
172 		Params:
173 			fileName = Name of the file which values will be stored.
174 	*/
175 	void save(string fileName) @trusted
176 	{
177 		if(fileName.length && valuesModified_)
178 		{
179 			auto configfile = File(fileName, "w+");
180 			string curGroup;
181 
182 			foreach(data; values_)
183 			{
184 				if(curGroup != data.group)
185 				{
186 					curGroup = data.group;
187 
188 					if(curGroup != DEFAULT_GROUP_NAME)
189 					{
190 						configfile.writeln("[", curGroup, "]");
191 					}
192 				}
193 
194 				if(data.comment.length)
195 				{
196 					configfile.writeln("#", data.comment);
197 				}
198 
199 				configfile.writeln(data.key, " = ", data.value);
200 			}
201 		}
202 	}
203 
204 	/**
205 		Loads a config fileName(app.config by default) to be processed.
206 
207 		Params:
208 			fileName = The name of the file to be processed/loaded.
209 		Returns:
210 			Returns true on a successful load false otherwise.
211 	*/
212 	bool loadFile(string fileName) @safe
213 	{
214 		saveToFileName_ = fileName;
215 
216 		if(fileName.exists)
217 		{
218 			return processText(fileName.readText);
219 		}
220 
221 		return false;
222 	}
223 
224 	/**
225 		Similar to loadFile but loads and processes the passed string instead.
226 
227 		Params:
228 			text = The string to process.
229 		Returns:
230 			Returns true on a successful load false otherwise.
231 	*/
232 
233 	bool loadString(const string text) @safe
234 	{
235 		if(text.length)
236 		{
237 			return processText(text);
238 		}
239 
240 		return false;
241 	}
242 
243 	/**
244 		Retrieves the value T associated with key where T is the designated type to be converted to.
245 
246 		Params:
247 			key = Name of the key to get.
248 			defaultValue = Allow the assignment of a default value if key does not exist.
249 
250 		Returns:
251 			The value associated with key.
252 
253 	*/
254 	Variant get(T)(const string key, T defaultValue) @trusted
255 	{
256 		if(isGroupString(key))
257 		{
258 			immutable auto groupAndKey = getGroupAndKeyFromString(key);
259 			return get(groupAndKey.group, groupAndKey.key, defaultValue);
260 		}
261 
262 		return get(DEFAULT_GROUP_NAME, key, defaultValue);
263 	}
264 
265 	/**
266 		Retrieves the value T associated with key where T is the designated type to be converted to.
267 
268 		Params:
269 			group = Name of the group to retrieve ie portion [groupName] of config file/string.
270 			key = Name of the key to get.
271 			defaultValue = Allow the assignment of a default value if key does not exist.
272 
273 		Returns:
274 			The value of value of the key/value pair.
275 
276 	*/
277 	Variant get(T)(const string group, const string key, T defaultValue) @trusted
278 	{
279 		if(containsGroup(group))
280 		{
281 			return getGroupValue(group, key, defaultValue);
282 		}
283 
284 		return Variant(defaultValue);
285 	}
286 
287 	/**
288 		Gets the value associated with the group and key.
289 
290 		Params:
291 			group = Name of the group the value is stored in.
292 			key = Name of the key the value is stored in.
293 			defaultValue = The value to use if group and or key is not found.
294 
295 		Returns:
296 			The value associated with the group and key.
297 	*/
298 	Variant getGroupValue(T)(const string group, const string key, const T defaultValue = T.init) @trusted
299 	{
300 		Variant value = defaultValue;
301 		auto found = values_.filter!(a => (a.group == group) && (a.key == key));
302 
303 		if(!found.empty)
304 		{
305 			value = found.front.value;
306 		}
307 
308 		return value;
309 	}
310 
311 	/**
312 		Retrieves key/values associated with the group portion of a config file/string.
313 
314 		Params:
315 			group = Name of the the group to retrieve.
316 
317 		Returns:
318 			Returns an array containing all the key/values associated with group.
319 
320 	*/
321 	auto getGroup(const string group) @trusted
322 	{
323 		return values_.filter!(a => a.group == group);
324 	}
325 
326 	/**
327 		Retrieves an array containing key/values of all groups in the configfile omitting groupless key/values.
328 
329 		Returns:
330 			An array containing every group.
331 	*/
332 	auto getGroups() @trusted
333 	{
334 		return values_.filter!(a => a.group != "");
335 	}
336 
337 	/**
338 		Sets a config value.
339 
340 		Params:
341 			key = Name of the key to set. Can be in the group.key form.
342 			value = The value to be set to.
343 	*/
344 	void set(T)(const string key, const T value) @trusted
345 	{
346 		if(isGroupString(key))
347 		{
348 			immutable auto groupAndKey = getGroupAndKeyFromString(key);
349 			set(groupAndKey.group, groupAndKey.key, value);
350 		}
351 		else
352 		{
353 			set(DEFAULT_GROUP_NAME, key, value);
354 		}
355 	}
356 
357 	/**
358 		Sets a config value.
359 
360 		Params:
361 			group = Name of the group key belongs to.
362 			key = Name of the key to set.
363 			value = The value to be set to.
364 	*/
365 	void set(T = string)(const string group, const string key, const T value) @trusted
366 	{
367 		auto foundValue = values_.filter!(a => (a.group == group) && (a.key == key));
368 
369 		if(foundValue.empty)
370 		{
371 			KeyValueData data;
372 
373 			data.key = key;
374 			data.group = group;
375 			data.value = value;
376 
377 			values_ ~= data;
378 		}
379 		else
380 		{
381 			foundValue.front.value = value;
382 		}
383 
384 		valuesModified_ = true;
385 	}
386 
387 	/**
388 		Determines if the key is found in the config file.
389 		The key can be either its name of in the format of groupName.keyName or just the key name.
390 
391 		Params:
392 			key = Name of the key to get the value of
393 
394 		Returns:
395 			true if the config file contains the key false otherwise.
396 	*/
397 	bool contains(const string key) @safe
398 	{
399 		if(isGroupString(key))
400 		{
401 			immutable auto groupAndKey = getGroupAndKeyFromString(key);
402 			return contains(groupAndKey.group, groupAndKey.key);
403 		}
404 
405 		return contains(DEFAULT_GROUP_NAME, key);
406 	}
407 
408 	/**
409 		Determines if the key is found in the config file.
410 
411 		Params:
412 			group = Name of the group to get entries from.
413 			key = Name of the key to get the value from.
414 
415 		Returns:
416 			true if the config file contains the key false otherwise.
417 	*/
418 	bool contains(const string group, const string key) @trusted
419 	{
420 		if(containsGroup(group))
421 		{
422 			auto groupValues = getGroup(group);
423 			return groupValues.canFind!(a => a.key == key);
424 		}
425 
426 		return false; // The group wasn't found so no point in checking for a group and value.
427 	}
428 
429 	/**
430 		Determines if the given group exists.
431 
432 		Params:
433 			group = Name of the group to check for.
434 
435 		Returns:
436 			true if the group exists false otherwise.
437 	*/
438 	bool containsGroup(const string group) @trusted
439 	{
440 		return values_.canFind!(a => a.group == group);
441 	}
442 
443 	/**
444 		Removes a key/value from config file.
445 		The key can be either its name of in the format of groupName.keyName or just the keyName.
446 
447 		Params:
448 			key = Name of the key to remove. Can be in the group.name format.
449 
450 		Returns:
451 			true if it was successfully removed false otherwise.
452 	*/
453 	bool remove(const string key) @trusted
454 	{
455 		if(isGroupString(key))
456 		{
457 			immutable auto groupAndKey = getGroupAndKeyFromString(key);
458 
459 			valuesModified_ = true;
460 			return remove(groupAndKey.group, groupAndKey.key);
461 		}
462 		else
463 		{
464 			valuesModified_ = true;
465 			return remove(DEFAULT_GROUP_NAME, key);
466 		}
467 	}
468 
469 	/**
470 		Removes a key/value from config file.
471 		The key can be either its name of in the format of group.keyor just the key.
472 
473 		Params:
474 			group = Name of the group where key is found.
475 			key = Name of the key to remove.
476 
477 		Returns:
478 			true if it was successfully removed false otherwise.
479 	*/
480 	bool remove(const string group, const string key) @trusted
481 	{
482 		values_ = values_.remove!(a => (a.group == group) && (a.key == key));
483 		valuesModified_ = true;
484 
485 		return contains(group, key);
486 	}
487 
488 	/**
489 		Removes a group from the config file.
490 
491 		Params:
492 			group = Name of the group to remove.
493 
494 		Returns:
495 			true if group was successfully removed false otherwise.
496 	*/
497 	bool removeGroup(const string group) @trusted
498 	{
499 		values_ = values_.remove!(a => a.group == group);
500 		valuesModified_ = true;
501 
502 		return containsGroup(group);
503 	}
504 
505 	/**
506 		Allows config values to be accessed as you would with an associative array.
507 
508 		Params:
509 			key = Name of the value to retrieve
510 
511 		Returns:
512 			The string value associated with the key.
513 	*/
514 	Variant opIndex(const string key) @trusted
515 	{
516 		Variant defaultValue;
517 
518 		return get(key, defaultValue);
519 	}
520 
521 	/**
522 		Allows config values to be assigned as you would with an associative array.
523 
524 		Params:
525 			key = Name of the key to assign the value to.
526 			value = The value in which key should be assigned to.
527 	*/
528 	void opIndexAssign(T)(T value, const string key) @trusted
529 	{
530 		set(key, value);
531 	}
532 
533 	/**
534 		Converts the value of key to type of T. Works the same as std.variant's coerce.
535 
536 		Params:
537 			key = Name of the key to retrieve.
538 			defaultValue = The value to use if key isn't found.
539 
540 		Returns:
541 			T = The converted value.
542 	*/
543 	T coerce(T = string)(const string key, const T defaultValue = T.init) @trusted
544 	{
545 		Variant value = defaultValue;
546 
547 		if(contains(key))
548 		{
549 			value = get(key, defaultValue);
550 		}
551 
552 		return value.coerce!T;
553 	}
554 
555 	/// Gets the value and converts it to a bool.
556 	alias asBool = coerce!bool;
557 
558 	/// Gets the value and converts it to a int.
559 	alias asInt = coerce!int;
560 
561 	/// Gets the value and converts it to a float.
562 	alias asFloat = coerce!float;
563 
564 	/// Gets the value and converts it to a real.
565 	alias asReal = coerce!real;
566 
567 	/// Gets the value and converts it to a long.
568 	alias asLong = coerce!long;
569 
570 	/// Gets the value and converts it to a byte.
571 	alias asByte = coerce!byte;
572 
573 	/// Gets the value and converts it to a short.
574 	alias asShort = coerce!short;
575 
576 	/// Gets the value and converts it to a double.
577 	alias asDouble = coerce!double;
578 
579 	/// Gets the value and converts it to a string.
580 	alias asString = coerce!string;
581 
582 	/// Simpler alias that conveys the meaning more appropriately.
583 	alias as = coerce;
584 
585 private:
586 	KeyValueData[] values_;
587 	bool valuesModified_;
588 	string saveToFileName_ = DEFAULT_CONFIG_FILE_NAME;
589 }
590 
591 ///
592 unittest
593 {
594 	string text = "
595 		aBool=true
596 		decimal = 3443.443
597 		number=12071
598 		#Here is a comment
599 		sentence=This is a really long sentence to test for a really long value string!
600 		time=12:04
601 		[section]
602 		groupSection=is really cool if this works!
603 		japan=true
604 		babymetal=the one
605 		[another]
606 		#And another comment!
607 		world=hello
608 		japan=false
609 	";
610 
611 	VariantConfig config;
612 
613 	immutable bool loaded = config.loadString(text);
614 	assert(loaded, "Failed to load string!");
615 
616 	assert(config.containsGroup("section"));
617 	config.removeGroup("section");
618 	assert(config.containsGroup("section") == false);
619 
620 	assert(config.get("aBool", true).coerce!bool == true);
621 	assert(config.asBool("aBool")); // Syntactic sugar
622 	assert(config["aBool"].coerce!bool == true); // Also works but rather awkward
623 	assert(config.coerce!bool("aBool") == true); // Also works and more natural
624 
625 	assert(config.contains("time"));
626 
627 	immutable auto number = config["number"];
628 
629 	assert(number == 12_071);
630 	assert(config["decimal"] == 3443.443);
631 
632 	assert(config.contains("another.world"));
633 	assert(config["another.world"] == "hello");
634 
635 	config["another.japan"] = true;
636 	assert(config["another.japan"] == true);
637 
638 	config.remove("another.world");
639 
640 	assert(config.contains("another.world") == false);
641 	assert(config.contains("anothers", "world") == false);
642 
643 	assert(config.contains("number"));
644 	config.remove("number");
645 	assert(config.contains("number") == false);
646 
647 	// Tests for nonexistent keys
648 	assert(config.asString("nonexistent", "Value doesn't exist!") == "Value doesn't exist!");
649 	config["nonexistent"] = "The value now exists!!!";
650 	assert(config.asString("nonexistent", "The value now exists!!!") == "The value now exists!!!");
651 
652 	auto group = config.getGroup("another");
653 
654 	foreach(value; group)
655 	{
656 		assert(value.key == "japan");
657 		assert(value.value == true);
658 	}
659 
660 	config.set("aBool", false);
661 	assert(config["aBool"] == false);
662 	debug config.save();
663 
664 	config["aBool"] = true;
665 	assert(config["aBool"] == true);
666 	assert(config["aBool"].toString == "true");
667 
668 	assert(config.get!int("numberGroup", "numberValue", 1234) == 1234);
669 
670 	immutable string customFileName = "custom-config-format.dat";
671 	debug config.save(customFileName);
672 
673 	VariantConfig configLoadTest;
674 
675 	bool isLoadedTest = configLoadTest.loadFile("doesnt-exist.dat");
676 	assert(isLoadedTest == false);
677 
678 	isLoadedTest = configLoadTest.loadFile(customFileName);
679 	assert(isLoadedTest == true);
680 
681 	string noEqualSign = "
682 		equal=sign
683 		time=12:04
684 		This is a really long sentence to test for a really long value string!
685 	";
686 
687 	immutable bool equalSignValue = config.loadString(noEqualSign);
688 	assert(equalSignValue == false);
689 
690 	auto groups = config.getGroups();
691 
692 	writeln("Listing groups: ");
693 	writeln;
694 
695 	foreach(currGroup; groups)
696 	{
697 		writeln(currGroup);
698 	}
699 
700 	string invalidGroup = "
701 		[first]
702 		equal=sign
703 		time=12:04
704 		[second
705 		another=key value is here
706 	";
707 
708 	immutable bool invalidGroupValue = config.loadString(invalidGroup);
709 	assert(invalidGroupValue == false);
710 
711 	string emptyString;
712 
713 	immutable bool emptyLoad = config.loadString(emptyString);
714 	assert(emptyLoad == false);
715 }