2022-09-24 00:13:08 +02:00
|
|
|
/**
|
|
|
|
* Authors:
|
|
|
|
* Mike Bierlee, m.bierlee@lostmoment.com
|
|
|
|
* Copyright: 2022 Mike Bierlee
|
|
|
|
* License:
|
|
|
|
* This software is licensed under the terms of the MIT license.
|
|
|
|
* The full terms of the license can be found in the LICENSE file.
|
|
|
|
*/
|
|
|
|
|
2022-09-24 15:58:49 +02:00
|
|
|
module mirage.config;
|
2022-09-23 22:34:46 +02:00
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
import std.exception : enforce;
|
2022-09-24 02:46:19 +02:00
|
|
|
import std.string : split, startsWith, endsWith, join, lastIndexOf;
|
2022-09-24 00:13:08 +02:00
|
|
|
import std.conv : to, ConvException;
|
2022-09-24 17:51:32 +02:00
|
|
|
import std.file : readText;
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
class ConfigReadException : Exception {
|
|
|
|
this(string msg, string file = __FILE__, size_t line = __LINE__) {
|
|
|
|
super(msg, file, line);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
class ConfigCreationException : Exception {
|
|
|
|
this(string msg, string file = __FILE__, size_t line = __LINE__) {
|
|
|
|
super(msg, file, line);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
class PathParseException : Exception {
|
|
|
|
this(string msg, string path, string file = __FILE__, size_t line = __LINE__) {
|
|
|
|
string fullMsg = msg ~ " (Path: " ~ path ~ ")";
|
|
|
|
super(fullMsg, file, line);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
interface ConfigNode {
|
2022-09-24 01:55:44 +02:00
|
|
|
string nodeType();
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
class ValueNode : ConfigNode {
|
2022-09-23 22:34:46 +02:00
|
|
|
string value;
|
|
|
|
|
|
|
|
this() {
|
|
|
|
}
|
|
|
|
|
|
|
|
this(string value) {
|
|
|
|
this.value = value;
|
|
|
|
}
|
2022-09-24 01:55:44 +02:00
|
|
|
|
|
|
|
string nodeType() {
|
|
|
|
return "value";
|
|
|
|
}
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
class ObjectNode : ConfigNode {
|
2022-09-23 22:34:46 +02:00
|
|
|
ConfigNode[string] children;
|
|
|
|
|
|
|
|
this() {
|
|
|
|
}
|
|
|
|
|
|
|
|
this(ConfigNode[string] children) {
|
|
|
|
this.children = children;
|
|
|
|
}
|
2022-09-24 01:07:31 +02:00
|
|
|
|
|
|
|
this(string[string] values) {
|
|
|
|
foreach (key, value; values) {
|
|
|
|
children[key] = new ValueNode(value);
|
|
|
|
}
|
|
|
|
}
|
2022-09-24 01:55:44 +02:00
|
|
|
|
|
|
|
string nodeType() {
|
|
|
|
return "object";
|
|
|
|
}
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
class ArrayNode : ConfigNode {
|
2022-09-23 22:34:46 +02:00
|
|
|
ConfigNode[] children;
|
|
|
|
|
|
|
|
this() {
|
|
|
|
}
|
|
|
|
|
|
|
|
this(ConfigNode[] children...) {
|
|
|
|
this.children = children;
|
|
|
|
}
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
this(string[] values...) {
|
|
|
|
foreach (string value; values) {
|
2022-09-24 00:15:16 +02:00
|
|
|
children ~= new ValueNode(value);
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
}
|
2022-09-24 01:55:44 +02:00
|
|
|
|
|
|
|
string nodeType() {
|
|
|
|
return "array";
|
|
|
|
}
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 16:05:47 +02:00
|
|
|
private interface PathSegment {
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 16:05:47 +02:00
|
|
|
private class ArrayPathSegment : PathSegment {
|
2022-09-24 00:13:08 +02:00
|
|
|
const size_t index;
|
|
|
|
|
|
|
|
this(const size_t index) {
|
|
|
|
this.index = index;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 16:05:47 +02:00
|
|
|
private class PropertyPathSegment : PathSegment {
|
2022-09-24 01:07:31 +02:00
|
|
|
const string propertyName;
|
|
|
|
|
|
|
|
this(const string propertyName) {
|
|
|
|
this.propertyName = propertyName;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 16:05:47 +02:00
|
|
|
private class ConfigPath {
|
2022-09-24 00:13:08 +02:00
|
|
|
private const string path;
|
2022-09-24 01:55:44 +02:00
|
|
|
private string[] previousSegments;
|
2022-09-24 00:13:08 +02:00
|
|
|
private string[] segments;
|
|
|
|
|
|
|
|
this(const string path) {
|
|
|
|
this.path = path;
|
2022-09-24 02:46:19 +02:00
|
|
|
segmentAndNormalize(path);
|
|
|
|
}
|
2022-09-24 02:32:30 +02:00
|
|
|
|
2022-09-24 02:46:19 +02:00
|
|
|
private void segmentAndNormalize(string path) {
|
2022-09-24 02:32:30 +02:00
|
|
|
foreach (segment; path.split(".")) {
|
2022-09-24 02:46:19 +02:00
|
|
|
if (segment.length <= 0) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (segment.endsWith("]") && !segment.startsWith("[")) {
|
|
|
|
auto openBracketPos = segment.lastIndexOf("[");
|
|
|
|
if (openBracketPos != -1) {
|
|
|
|
segments ~= segment[0 .. openBracketPos];
|
|
|
|
segments ~= segment[openBracketPos .. $];
|
|
|
|
continue;
|
|
|
|
}
|
2022-09-24 02:32:30 +02:00
|
|
|
}
|
2022-09-24 02:46:19 +02:00
|
|
|
|
|
|
|
segments ~= segment;
|
2022-09-24 02:32:30 +02:00
|
|
|
}
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
PathSegment getNextSegment() {
|
|
|
|
if (segments.length == 0) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
PathSegment ret(PathSegment segment) {
|
2022-09-24 01:55:44 +02:00
|
|
|
previousSegments ~= segments[0];
|
|
|
|
segments = segments[1 .. $];
|
2022-09-24 00:13:08 +02:00
|
|
|
return segment;
|
|
|
|
}
|
|
|
|
|
|
|
|
string segment = segments[0];
|
|
|
|
|
|
|
|
if (segment.startsWith("[") && segment.endsWith("]")) {
|
|
|
|
if (segment.length <= 2) {
|
|
|
|
throw new PathParseException("Path has array accessor but no index specified", path);
|
|
|
|
}
|
|
|
|
|
|
|
|
auto indexString = segment[1 .. $ - 1];
|
|
|
|
try {
|
|
|
|
auto index = indexString.to!size_t;
|
|
|
|
return ret(new ArrayPathSegment(index));
|
|
|
|
} catch (ConvException e) {
|
2022-09-24 17:05:33 +02:00
|
|
|
throw new PathParseException("Value '" ~ indexString ~ "' is not acceptable as an array index", path);
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 01:07:31 +02:00
|
|
|
return ret(new PropertyPathSegment(segment));
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
2022-09-24 01:55:44 +02:00
|
|
|
|
|
|
|
string getCurrentPath() {
|
|
|
|
return previousSegments.join(".");
|
|
|
|
}
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
class ConfigDictionary {
|
|
|
|
ConfigNode rootNode;
|
2022-09-24 00:13:08 +02:00
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
this() {
|
|
|
|
}
|
|
|
|
|
|
|
|
this(ConfigNode rootNode) {
|
|
|
|
this.rootNode = rootNode;
|
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
string get(string configPath) {
|
|
|
|
enforce!ConfigReadException(rootNode !is null, "The config is empty");
|
2022-09-24 16:09:20 +02:00
|
|
|
// enforce!ConfigReadException(configPath.length > 0, "Supplied config path is empty");
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
auto path = new ConfigPath(configPath);
|
|
|
|
auto currentNode = rootNode;
|
|
|
|
PathSegment currentPathSegment = path.getNextSegment();
|
2022-09-24 02:18:49 +02:00
|
|
|
|
2022-09-24 01:55:44 +02:00
|
|
|
string createExceptionPath() {
|
|
|
|
return "'" ~ configPath ~ "' (at '" ~ path.getCurrentPath() ~ "')";
|
|
|
|
}
|
|
|
|
|
2022-09-24 02:18:49 +02:00
|
|
|
void throwPathNotExists() {
|
|
|
|
throw new ConfigReadException("Path does not exist: " ~ createExceptionPath());
|
|
|
|
}
|
|
|
|
|
|
|
|
void ifNotNullPointer(void* obj, void delegate() fn) {
|
|
|
|
if (obj) {
|
|
|
|
fn();
|
|
|
|
} else {
|
|
|
|
throwPathNotExists();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
void ifNotNull(Object obj, void delegate() fn) {
|
|
|
|
if (obj) {
|
|
|
|
fn();
|
|
|
|
} else {
|
|
|
|
throwPathNotExists();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
while (currentPathSegment !is null) {
|
2022-09-24 01:55:44 +02:00
|
|
|
if (currentNode is null) {
|
2022-09-24 02:18:49 +02:00
|
|
|
throwPathNotExists();
|
2022-09-24 01:55:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
auto valueNode = cast(ValueNode) currentNode;
|
|
|
|
if (valueNode) {
|
2022-09-24 02:18:49 +02:00
|
|
|
throwPathNotExists();
|
2022-09-24 01:55:44 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
auto arrayPath = cast(ArrayPathSegment) currentPathSegment;
|
|
|
|
if (arrayPath) {
|
2022-09-24 00:15:16 +02:00
|
|
|
auto arrayNode = cast(ArrayNode) currentNode;
|
2022-09-24 02:18:49 +02:00
|
|
|
ifNotNull(arrayNode, {
|
2022-09-24 01:07:31 +02:00
|
|
|
if (arrayNode.children.length < arrayPath.index) {
|
2022-09-24 01:55:44 +02:00
|
|
|
throw new ConfigReadException(
|
|
|
|
"Array index out of bounds: " ~ createExceptionPath());
|
2022-09-24 01:07:31 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
currentNode = arrayNode.children[arrayPath.index];
|
2022-09-24 02:18:49 +02:00
|
|
|
});
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 01:07:31 +02:00
|
|
|
auto propertyPath = cast(PropertyPathSegment) currentPathSegment;
|
|
|
|
if (propertyPath) {
|
|
|
|
auto objectNode = cast(ObjectNode) currentNode;
|
2022-09-24 02:18:49 +02:00
|
|
|
ifNotNull(objectNode, {
|
2022-09-24 01:07:31 +02:00
|
|
|
auto propertyNode = propertyPath.propertyName in objectNode.children;
|
2022-09-24 02:18:49 +02:00
|
|
|
ifNotNullPointer(propertyNode, {
|
2022-09-24 01:07:31 +02:00
|
|
|
currentNode = *propertyNode;
|
2022-09-24 02:18:49 +02:00
|
|
|
});
|
|
|
|
});
|
2022-09-24 01:07:31 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 00:13:08 +02:00
|
|
|
currentPathSegment = path.getNextSegment();
|
|
|
|
}
|
|
|
|
|
2022-09-24 00:15:16 +02:00
|
|
|
auto value = cast(ValueNode) currentNode;
|
2022-09-24 00:13:08 +02:00
|
|
|
if (value) {
|
|
|
|
return value.value;
|
|
|
|
} else {
|
|
|
|
throw new ConfigReadException(
|
2022-09-24 01:55:44 +02:00
|
|
|
"Value expected but " ~ currentNode.nodeType ~ " found at path: " ~ createExceptionPath());
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
}
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 17:51:32 +02:00
|
|
|
abstract class ConfigFactory {
|
|
|
|
ConfigDictionary loadFile(string path) {
|
|
|
|
auto json = readText(path);
|
|
|
|
return parseConfig(json);
|
|
|
|
}
|
|
|
|
|
2022-09-24 17:05:33 +02:00
|
|
|
ConfigDictionary parseConfig(string contents);
|
2022-09-24 16:05:47 +02:00
|
|
|
}
|
|
|
|
|
2022-09-23 22:34:46 +02:00
|
|
|
version (unittest) {
|
2022-09-24 00:13:08 +02:00
|
|
|
import std.exception : assertThrown;
|
|
|
|
|
2022-09-23 22:34:46 +02:00
|
|
|
@("Dictionary creation")
|
|
|
|
unittest {
|
2022-09-24 00:15:16 +02:00
|
|
|
auto root = new ObjectNode([
|
|
|
|
"english": new ArrayNode([new ValueNode("one"), new ValueNode("two")]),
|
|
|
|
"spanish": new ArrayNode(new ValueNode("uno"), new ValueNode("dos"))
|
2022-09-23 22:34:46 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = root;
|
|
|
|
}
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
@("Get value in dictionary with empty root fails")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("."));
|
|
|
|
}
|
|
|
|
|
2022-09-24 16:09:20 +02:00
|
|
|
@("Get value in root with empty path")
|
2022-09-24 00:13:08 +02:00
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
2022-09-24 00:15:16 +02:00
|
|
|
dictionary.rootNode = new ValueNode("hehehe");
|
2022-09-24 00:13:08 +02:00
|
|
|
|
2022-09-24 16:09:20 +02:00
|
|
|
assert(dictionary.get("") == "hehehe");
|
2022-09-24 00:13:08 +02:00
|
|
|
}
|
|
|
|
|
2022-09-24 16:09:20 +02:00
|
|
|
@("Get value in root with just a dot")
|
2022-09-24 00:13:08 +02:00
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
2022-09-24 00:15:16 +02:00
|
|
|
dictionary.rootNode = new ValueNode("yup");
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
assert(dictionary.get(".") == "yup");
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Get value in root fails when root is not a value")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
2022-09-24 00:15:16 +02:00
|
|
|
dictionary.rootNode = new ArrayNode();
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("."));
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Get array value from root")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
2022-09-24 00:15:16 +02:00
|
|
|
dictionary.rootNode = new ArrayNode("aap", "noot", "mies");
|
2022-09-24 00:13:08 +02:00
|
|
|
|
|
|
|
assert(dictionary.get("[0]") == "aap");
|
|
|
|
assert(dictionary.get("[1]") == "noot");
|
|
|
|
assert(dictionary.get("[2]") == "mies");
|
|
|
|
}
|
2022-09-24 01:07:31 +02:00
|
|
|
|
|
|
|
@("Get value from object at root")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode([
|
|
|
|
"aap": "monkey",
|
|
|
|
"noot": "nut",
|
|
|
|
"mies": "mies" // It's a name!
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get("aap") == "monkey");
|
|
|
|
assert(dictionary.get("noot") == "nut");
|
|
|
|
assert(dictionary.get("mies") == "mies");
|
|
|
|
}
|
2022-09-24 01:55:44 +02:00
|
|
|
|
|
|
|
@("Get value from object in object")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode([
|
|
|
|
"server": new ObjectNode([
|
|
|
|
"port": "8080"
|
|
|
|
])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get("server.port") == "8080");
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Get value from array in object")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode([
|
|
|
|
"hostname": new ArrayNode(["google.com", "dlang.org"])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get("hostname.[1]") == "dlang.org");
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Exception is thrown when array out of bounds when fetching from root")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ArrayNode(["google.com", "dlang.org"]);
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("[5]"));
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Exception is thrown when array out of bounds when fetching from object")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode([
|
|
|
|
"hostname": new ArrayNode(["google.com", "dlang.org"])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("hostname.[5]"));
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Exception is thrown when path does not exist")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode(
|
|
|
|
[
|
|
|
|
"hostname": new ObjectNode(["cluster": new ValueNode("")])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("hostname.cluster.spacey"));
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Exception is thrown when given path terminates too early")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode(
|
|
|
|
[
|
|
|
|
"hostname": new ObjectNode(["cluster": new ValueNode(null)])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("hostname"));
|
|
|
|
}
|
|
|
|
|
2022-09-24 02:18:49 +02:00
|
|
|
@("Exception is thrown when given path does not exist because config is an array")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ArrayNode();
|
|
|
|
|
|
|
|
assertThrown!ConfigReadException(dictionary.get("hostname"));
|
|
|
|
}
|
|
|
|
|
2022-09-24 02:27:41 +02:00
|
|
|
@("Get value from objects in array")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ArrayNode(
|
|
|
|
new ObjectNode(["wrong": "yes"]),
|
|
|
|
new ObjectNode(["wrong": "no"]),
|
|
|
|
new ObjectNode(["wrong": "very"]),
|
|
|
|
);
|
|
|
|
|
|
|
|
assert(dictionary.get("[1].wrong") == "no");
|
|
|
|
}
|
|
|
|
|
|
|
|
@("Get value from config with mixed types")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode([
|
|
|
|
"uno": cast(ConfigNode) new ValueNode("one"),
|
|
|
|
"dos": cast(ConfigNode) new ArrayNode(["nope", "two"]),
|
|
|
|
"tres": cast(ConfigNode) new ObjectNode(["thisone": "three"])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get("uno") == "one");
|
|
|
|
assert(dictionary.get("dos.[1]") == "two");
|
|
|
|
assert(dictionary.get("tres.thisone") == "three");
|
|
|
|
}
|
|
|
|
|
2022-09-24 02:32:30 +02:00
|
|
|
@("Ignore empty segments")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode(
|
|
|
|
[
|
|
|
|
"one": new ObjectNode(["two": new ObjectNode(["three": "four"])])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get(".one..two...three....") == "four");
|
|
|
|
}
|
|
|
|
|
2022-09-24 02:46:19 +02:00
|
|
|
@("Support conventional array indexing notation")
|
|
|
|
unittest {
|
|
|
|
auto dictionary = new ConfigDictionary();
|
|
|
|
dictionary.rootNode = new ObjectNode(
|
|
|
|
[
|
|
|
|
"one": new ObjectNode(["two": new ArrayNode(["dino", "mino"])])
|
|
|
|
]);
|
|
|
|
|
|
|
|
assert(dictionary.get("one.two[1]") == "mino");
|
|
|
|
}
|
2022-09-23 22:34:46 +02:00
|
|
|
}
|