1 /** 2 Validate and parse config files. 3 4 Copyright: © 2019 Arne Ludwig <arne.ludwig@posteo.de> 5 License: Subject to the terms of the MIT license, as written in the 6 included LICENSE file. 7 Authors: Arne Ludwig <arne.ludwig@posteo.de> 8 */ 9 module darg_plus.configfile; 10 11 import darg : 12 Argument, 13 isArgumentHandler, 14 isOptionHandler, 15 Option, 16 OptionFlag; 17 import vibe.data.json : Json; 18 19 /// 20 unittest 21 { 22 import darg : Multiplicity; 23 import vibe.data.json; 24 25 auto config = serializeToJson([ 26 "file": Json("/path/to/file"), 27 "more_files": serializeToJson([ 28 "/path/to/file1", 29 "/path/to/file2", 30 ]), 31 "num": Json(42), 32 "verbose": Json(true), 33 ]); 34 35 struct Options 36 { 37 @Argument("<in:file>") 38 string file; 39 40 @Argument("<in:more_files>", Multiplicity.zeroOrMore) 41 string[] moreFiles; 42 43 @Option("num") 44 size_t num; 45 46 @Option("verbose") 47 OptionFlag verbose; 48 } 49 50 auto options = parseConfig!Options(config); 51 52 assert(options.file == "/path/to/file"); 53 assert(options.moreFiles == [ 54 "/path/to/file1", 55 "/path/to/file2", 56 ]); 57 assert(options.num == 42); 58 assert(options.verbose == true); 59 } 60 61 /// 62 unittest 63 { 64 import vibe.data.json; 65 66 auto config = serializeToJson([ 67 "file": Json("/path/to/file"), 68 "num": Json(42), 69 ]); 70 71 struct Options 72 { 73 @Argument("<in:file>") 74 string file; 75 76 @Option("num") 77 size_t num = 1337; 78 } 79 80 Options options; 81 82 // Config values override default option values and arguments that are 83 // marked as empty: 84 options.file = configEmptyArgument; 85 assert(options.num == 1337); 86 87 options = retroInitFromConfig(options, config); 88 89 assert(options.file == "/path/to/file"); 90 assert(options.num == 42); 91 92 // Modified values, ie. given on the CLI, have precedence 93 // over config values: 94 options.file = "/path/from/cli"; 95 options.num = 13; 96 97 assert(options.file == "/path/from/cli"); 98 assert(options.num == 13); 99 } 100 101 /// String-type values equal to this string are considered empty. 102 enum configEmptyArgument = "-"; 103 104 /// Keys prefixed with this string are ignored. 105 enum configCommentPrefix = "//"; 106 107 /// Maximum size of a valid config file. 108 enum maxConfigSize = toBytes(256, SizeUnit.MiB); 109 110 111 /// Thrown if an error while handling config file occurs. 112 class ConfigFileException : Exception 113 { 114 string configKey; 115 Json configValue; 116 117 /** 118 Params: 119 msg = The message for the exception. 120 next = The previous exception in the chain of exceptions. 121 file = The file where the exception occurred. 122 line = The line number where the exception occurred. 123 */ 124 this(string msg, Throwable next, string file = __FILE__, 125 size_t line = __LINE__) @nogc @safe pure nothrow 126 { 127 super(msg, file, line, next); 128 } 129 130 /** 131 Params: 132 msg = The message for the exception. 133 configKey = Key of the erroneous config entry. 134 configValue = Value of the erroneous config entry. 135 file = The file where the exception occurred. 136 line = The line number where the exception occurred. 137 next = The previous exception in the chain of exceptions, if any. 138 */ 139 this(string msg, string configKey, Json configValue = Json.init, string file = __FILE__, size_t line = __LINE__, 140 Throwable next = null) @nogc @safe pure nothrow 141 { 142 super(msg, file, line, next); 143 this.configKey = configKey; 144 this.configValue = configValue; 145 } 146 147 /** 148 Params: 149 msg = The message for the exception. 150 configKey = Key of the erroneous config entry. 151 configValue = Value of the erroneous config entry. 152 next = The previous exception in the chain of exceptions. 153 file = The file where the exception occurred. 154 line = The line number where the exception occurred. 155 */ 156 this(string msg, string configKey, Json configValue, Throwable next, string file = __FILE__, 157 size_t line = __LINE__) @nogc @safe pure nothrow 158 { 159 super(msg, file, line, next); 160 this.configKey = configKey; 161 this.configValue = configValue; 162 } 163 } 164 165 T enforce(T)( 166 T value, 167 lazy string message, 168 lazy string configKey = null, 169 lazy Json configValue = Json.init, 170 string file = __FILE__, 171 size_t line = __LINE__, 172 ) 173 { 174 static import std.exception; 175 176 return std.exception.enforce(value, new ConfigFileException( 177 message, 178 configKey, 179 configValue, 180 file, 181 line, 182 )); 183 } 184 185 186 /// Retroactively initialize options from config. 187 Options retroInitFromConfig(Options)(ref Options options, in string configFile) 188 { 189 return retroInitFromConfig(options, parseConfig!Options(configFile)); 190 } 191 192 193 /// ditto 194 Options retroInitFromConfig(Options)(ref Options options, in Json config) 195 { 196 import std.format : format; 197 import std.meta : Alias; 198 import std.traits : 199 getUDAs, 200 isArray, 201 isFloatingPoint, 202 isSomeString, 203 isSomeString, 204 isStaticArray; 205 import std.math : isNaN; 206 207 enum defaultOptions = Options.init; 208 Options optionsFromConfig = parseConfig!Options(config); 209 210 static foreach (member; __traits(allMembers, Options)) 211 {{ 212 alias symbol = Alias!(__traits(getMember, options, member)); 213 enum isMemberAssignable = __traits(compiles, 214 __traits(getMember, options, member) = __traits(getMember, options, member) 215 ); 216 217 static if (isMemberAssignable) 218 { 219 alias Member = typeof(__traits(getMember, options, member)); 220 enum unaryMixin(string template_) = format!template_(member); 221 enum binaryMixin(string template_) = format!template_(member, member); 222 alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s"); 223 224 static if (getUDAs!(symbol, Argument).length > 0) 225 { 226 static if (isSomeString!Member) 227 { 228 if (mixin(unaryMixin!"options.%s == configEmptyArgument")) 229 assignConfigValue(); 230 } 231 else static if (isArray!Member && isSomeString!(ElementType!Member)) 232 { 233 if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)")) 234 assignConfigValue(); 235 } 236 } 237 else 238 { 239 static if (isStaticArray!Member || is(Member == class)) 240 { 241 if (mixin(binaryMixin!"options.%s == defaultOptions.%s")) 242 assignConfigValue(); 243 } 244 else static if (isFloatingPoint!Member) 245 { 246 if ( 247 mixin(binaryMixin!"options.%s == defaultOptions.%s") || 248 ( 249 mixin(unaryMixin!"options.%s.isNaN") && 250 mixin(unaryMixin!"defaultOptions.%s.isNaN") 251 ) 252 ) 253 assignConfigValue(); 254 } 255 else 256 { 257 if (mixin(binaryMixin!"options.%s is defaultOptions.%s")) 258 assignConfigValue(); 259 } 260 } 261 } 262 }} 263 264 return options; 265 } 266 267 268 /// Initialize options using config. 269 Options parseConfig(Options)(in string configFile) 270 { 271 import vibe.data.json : parseJson; 272 273 auto configContent = readConfigFile(configFile); 274 auto configValues = parseJson( 275 configContent, 276 null, 277 configFile, 278 ); 279 280 return parseConfig!Options(configValues); 281 } 282 283 /// ditto 284 Options parseConfig(Options)(in Json config) 285 { 286 import std.meta : Alias; 287 288 validateConfig!Options(config); 289 290 Options options; 291 292 foreach (member; __traits(allMembers, Options)) 293 { 294 alias symbol = Alias!(__traits(getMember, options, member)); 295 enum names = configNamesOf!symbol; 296 297 static if (names.length > 0) 298 { 299 foreach (name; names) 300 if (name in config) 301 options.assignConfigValue!member(name, config[name]); 302 } 303 } 304 305 return options; 306 } 307 308 /// Validate config. 309 void validateConfigFile(Options)(in string configFile) 310 { 311 import vibe.data.json : parseJson; 312 313 auto configContent = readConfigFile(configFile); 314 auto configValues = parseJson( 315 configContent, 316 null, 317 configFile, 318 ); 319 320 validateConfig!Options(configValues); 321 } 322 323 /// ditto 324 void validateConfig(Options)(in Json config) 325 { 326 import std.algorithm : startsWith; 327 import std.format : format; 328 import std.meta : Alias; 329 330 enforce(config.type == Json.Type.object, "config must contain a single object"); 331 332 configLoop: foreach (configKey, configValue; config.byKeyValue) 333 { 334 if (configKey.startsWith(configCommentPrefix)) 335 continue; 336 337 foreach (member; __traits(allMembers, Options)) 338 { 339 alias symbol = Alias!(__traits(getMember, Options, member)); 340 enum names = configNamesOf!symbol; 341 342 static if (names.length > 0) 343 { 344 alias SymbolType = typeof(__traits(getMember, Options, member)); 345 346 foreach (name; names) 347 { 348 try 349 { 350 if (name == configKey) 351 { 352 cast(void) getConfigValue!SymbolType(configKey, configValue); 353 continue configLoop; 354 } 355 } 356 catch (Exception cause) 357 { 358 throw new ConfigFileException( 359 format!"malformed config value `%s`: %s"( 360 configKey, 361 cause.msg, 362 ), 363 configKey, 364 configValue, 365 cause, 366 ); 367 } 368 } 369 } 370 } 371 372 throw new ConfigFileException( 373 format!"invalid config key `%s`"( 374 configKey, 375 ), 376 configKey, 377 ); 378 } 379 } 380 381 template configNamesOf(alias symbol) 382 { 383 import std.array : split; 384 import std.traits : getUDAs; 385 386 alias optUDAs = getUDAs!(symbol, Option); 387 alias argUDAs = getUDAs!(symbol, Argument); 388 389 static if (argUDAs.length > 0) 390 enum argName = argUDAs[0].name.split(":")[$ - 1][0 .. $ - 1]; 391 392 static if (optUDAs.length > 0 && argUDAs.length > 0) 393 { 394 enum configNamesOf = optUDAs[0].names ~ argName; 395 } 396 else static if (optUDAs.length > 0) 397 { 398 enum configNamesOf = optUDAs[0].names; 399 } 400 else static if (argUDAs.length > 0) 401 { 402 enum configNamesOf = [argName]; 403 } 404 else 405 { 406 enum string[] configNamesOf = []; 407 } 408 } 409 410 void assignConfigValue(string member, Options)(ref Options options, string configKey, Json configValue) 411 { 412 import std.conv : to; 413 import std.traits : isAssignable; 414 415 alias SymbolType = typeof(__traits(getMember, options, member)); 416 417 static if (isOptionHandler!SymbolType) 418 { 419 if (configValue.type == Json.Type.int_) 420 foreach (i; 0 .. configValue.get!ulong) 421 __traits(getMember, options, member)(); 422 else if (configValue.type == Json.Type.bool_) 423 { 424 if (configValue.get!bool) 425 __traits(getMember, options, member)(); 426 } 427 else 428 throw new ConfigFileException( 429 "Got JSON of type " ~ configValue.type.to!string ~ 430 ", expected int_ or bool_.", 431 configKey, 432 configValue, 433 ); 434 } 435 else static if (isArgumentHandler!SymbolType) 436 { 437 if (configValue.type == Json.Type.array) 438 foreach (item; configValue.get!(Json[])) 439 __traits(getMember, options, member)(item.get!string); 440 else if (configValue.type == Json.Type..string) 441 __traits(getMember, options, member)(configValue.get!string); 442 else 443 throw new ConfigFileException( 444 "Got JSON of type " ~ configValue.type.to!string ~ 445 ", expected array or string_.", 446 configKey, 447 configValue, 448 ); 449 } 450 else static if (isAssignable!SymbolType) 451 { 452 __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue); 453 } 454 } 455 456 auto getConfigValue(SymbolType)(string configKey, Json configValue) 457 { 458 import std.conv : to; 459 import std.range.primitives : ElementType; 460 import std.traits : 461 isArray, 462 isDynamicArray, 463 isFloatingPoint, 464 isIntegral, 465 isSomeString, 466 isUnsigned; 467 468 static if (is(SymbolType == OptionFlag)) 469 return configValue.get!bool.to!SymbolType; 470 else static if (is(SymbolType == enum)) 471 return configValue.get!string.to!SymbolType; 472 else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool)) 473 return configValue.get!bool.to!SymbolType; 474 else static if (isFloatingPoint!SymbolType) 475 return configValue.get!double.to!SymbolType; 476 else static if (isIntegral!SymbolType && isUnsigned!SymbolType) 477 return configValue.get!ulong.to!SymbolType; 478 else static if (isIntegral!SymbolType && !isUnsigned!SymbolType) 479 return configValue.get!long.to!SymbolType; 480 else static if (isSomeString!SymbolType) 481 { 482 if (configValue.type == Json.Type..string) 483 return configValue.get!string.to!SymbolType; 484 else if (configValue.type == Json.Type.null_) 485 return null; 486 else 487 throw new ConfigFileException( 488 "Got JSON of type " ~ configValue.type.to!string ~ 489 ", expected string or null_.", 490 configKey, 491 configValue, 492 ); 493 } 494 else static if (isArray!SymbolType) 495 { 496 SymbolType value; 497 498 static if (isDynamicArray!SymbolType) 499 value.length = configValue.length; 500 else 501 enforce( 502 configValue.length == value.length, 503 "array must have " ~ value.length ~ " elements", 504 configKey, 505 configValue, 506 ); 507 508 foreach (size_t i, configElement; configValue.get!(Json[])) 509 value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement); 510 511 return value; 512 } 513 } 514 515 string readConfigFile(in string configFileName) 516 { 517 import std.stdio : File; 518 import std.format : format; 519 520 auto configFile = File(configFileName, "r"); 521 auto configFileSize = configFile.size; 522 523 enforce( 524 configFileSize <= maxConfigSize, 525 format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand), 526 ); 527 528 auto configContent = configFile.rawRead(new char[configFileSize]); 529 530 return cast(string) configContent; 531 } 532 533 /// Units for bytes. 534 enum SizeUnit 535 { 536 B, 537 KiB, 538 MiB, 539 GiB, 540 TiB, 541 PiB, 542 EiB, 543 ZiB, 544 YiB, 545 } 546 547 /// Convert a value and unit to number of bytes. 548 auto toBytes(in size_t value, in SizeUnit unit) 549 { 550 return value * sizeUnitBase^^unit; 551 } 552 553 /// Convert bytes to 554 auto fromBytes(in size_t bytes) 555 { 556 import std.conv : to; 557 import std.typecons : tuple; 558 import std.traits : EnumMembers; 559 560 alias convertToUnit = exp => tuple!("value", "unit")( 561 bytes.to!double / (sizeUnitBase^^exp), 562 exp, 563 ); 564 565 foreach (exp; EnumMembers!SizeUnit) 566 { 567 if (bytes <= sizeUnitBase^^exp) 568 return convertToUnit(exp); 569 } 570 571 return convertToUnit(SizeUnit.max); 572 } 573 574 private enum size_t sizeUnitBase = 2^^10;