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 /// ditto 193 Options retroInitFromConfig(Options)(ref Options options, in Json config) 194 { 195 return retroInitFromConfig(options, parseConfig!Options(config)); 196 } 197 198 /// ditto 199 Options retroInitFromConfig(Options)(ref Options options, Options optionsFromConfig) 200 { 201 import std.algorithm : all; 202 import std.format : format; 203 import std.math : isNaN; 204 import std.meta : Alias; 205 import std.range.primitives : ElementType; 206 import std.traits : 207 getUDAs, 208 isArray, 209 isFloatingPoint, 210 isSomeString, 211 isSomeString, 212 isStaticArray; 213 214 enum defaultOptions = Options.init; 215 216 static foreach (member; __traits(allMembers, Options)) 217 {{ 218 alias symbol = Alias!(__traits(getMember, options, member)); 219 enum isMemberAssignable = __traits(compiles, 220 __traits(getMember, options, member) = __traits(getMember, options, member) 221 ); 222 223 static if (isMemberAssignable) 224 { 225 alias Member = typeof(__traits(getMember, options, member)); 226 enum unaryMixin(string template_) = format!template_(member); 227 enum binaryMixin(string template_) = format!template_(member, member); 228 alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s"); 229 230 static if (getUDAs!(symbol, Argument).length > 0) 231 { 232 static if (isSomeString!Member) 233 { 234 if (mixin(unaryMixin!"options.%s == configEmptyArgument")) 235 assignConfigValue(); 236 } 237 else static if (isArray!Member && isSomeString!(ElementType!Member)) 238 { 239 if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)")) 240 assignConfigValue(); 241 } 242 } 243 else 244 { 245 static if (isStaticArray!Member || is(Member == class)) 246 { 247 if (mixin(binaryMixin!"options.%s == defaultOptions.%s")) 248 assignConfigValue(); 249 } 250 else static if (isFloatingPoint!Member) 251 { 252 if ( 253 mixin(binaryMixin!"options.%s == defaultOptions.%s") || 254 ( 255 mixin(unaryMixin!"options.%s.isNaN") && 256 mixin(unaryMixin!"defaultOptions.%s.isNaN") 257 ) 258 ) 259 assignConfigValue(); 260 } 261 else 262 { 263 if (mixin(binaryMixin!"options.%s is defaultOptions.%s")) 264 assignConfigValue(); 265 } 266 } 267 } 268 }} 269 270 return options; 271 } 272 273 274 /// Initialize options using config. 275 Options parseConfig(Options)(in string configFile) 276 { 277 import vibe.data.json : parseJson; 278 279 auto configContent = readConfigFile(configFile); 280 auto configValues = parseJson( 281 configContent, 282 null, 283 configFile, 284 ); 285 286 return parseConfig!Options(configValues); 287 } 288 289 /// ditto 290 Options parseConfig(Options)(in Json config) 291 { 292 import std.meta : Alias; 293 294 validateConfig!Options(config); 295 296 Options options; 297 298 foreach (member; __traits(allMembers, Options)) 299 { 300 alias symbol = Alias!(__traits(getMember, options, member)); 301 enum names = configNamesOf!symbol; 302 303 static if (names.length > 0) 304 { 305 foreach (name; names) 306 if (name in config) 307 options.assignConfigValue!member(name, config[name]); 308 } 309 } 310 311 return options; 312 } 313 314 /// Validate config. 315 void validateConfigFile(Options)(in string configFile) 316 { 317 import vibe.data.json : parseJson; 318 319 auto configContent = readConfigFile(configFile); 320 auto configValues = parseJson( 321 configContent, 322 null, 323 configFile, 324 ); 325 326 validateConfig!Options(configValues); 327 } 328 329 /// ditto 330 void validateConfig(Options)(in Json config) 331 { 332 import std.algorithm : startsWith; 333 import std.format : format; 334 import std.meta : Alias; 335 336 enforce(config.type == Json.Type.object, "config must contain a single object"); 337 338 configLoop: foreach (configKey, configValue; config.byKeyValue) 339 { 340 if (configKey.startsWith(configCommentPrefix)) 341 continue; 342 343 foreach (member; __traits(allMembers, Options)) 344 { 345 alias symbol = Alias!(__traits(getMember, Options, member)); 346 enum names = configNamesOf!symbol; 347 348 static if (names.length > 0) 349 { 350 alias SymbolType = typeof(__traits(getMember, Options, member)); 351 352 foreach (name; names) 353 { 354 try 355 { 356 if (name == configKey) 357 { 358 cast(void) getConfigValue!SymbolType(configKey, configValue); 359 continue configLoop; 360 } 361 } 362 catch (Exception cause) 363 { 364 throw new ConfigFileException( 365 format!"malformed config value `%s`: %s"( 366 configKey, 367 cause.msg, 368 ), 369 configKey, 370 configValue, 371 cause, 372 ); 373 } 374 } 375 } 376 } 377 378 throw new ConfigFileException( 379 format!"invalid config key `%s`"( 380 configKey, 381 ), 382 configKey, 383 ); 384 } 385 } 386 387 template configNamesOf(alias symbol) 388 { 389 import std.array : split; 390 import std.traits : getUDAs; 391 392 alias optUDAs = getUDAs!(symbol, Option); 393 alias argUDAs = getUDAs!(symbol, Argument); 394 395 static if (argUDAs.length > 0) 396 enum argName = argUDAs[0].name.split(":")[$ - 1][0 .. $ - 1]; 397 398 static if (optUDAs.length > 0 && argUDAs.length > 0) 399 { 400 enum configNamesOf = optUDAs[0].names ~ argName; 401 } 402 else static if (optUDAs.length > 0) 403 { 404 enum configNamesOf = optUDAs[0].names; 405 } 406 else static if (argUDAs.length > 0) 407 { 408 enum configNamesOf = [argName]; 409 } 410 else 411 { 412 enum string[] configNamesOf = []; 413 } 414 } 415 416 void assignConfigValue(string member, Options)(ref Options options, string configKey, Json configValue) 417 { 418 import std.conv : to; 419 import std.traits : isAssignable; 420 421 alias SymbolType = typeof(__traits(getMember, options, member)); 422 423 static if (isOptionHandler!SymbolType) 424 { 425 if (configValue.type == Json.Type.int_) 426 foreach (i; 0 .. configValue.get!ulong) 427 __traits(getMember, options, member)(); 428 else if (configValue.type == Json.Type.bool_) 429 { 430 if (configValue.get!bool) 431 __traits(getMember, options, member)(); 432 } 433 else 434 throw new ConfigFileException( 435 "Got JSON of type " ~ configValue.type.to!string ~ 436 ", expected int_ or bool_.", 437 configKey, 438 configValue, 439 ); 440 } 441 else static if (isArgumentHandler!SymbolType) 442 { 443 if (configValue.type == Json.Type.array) 444 foreach (item; configValue.get!(Json[])) 445 __traits(getMember, options, member)(item.get!string); 446 else if (configValue.type == Json.Type..string) 447 __traits(getMember, options, member)(configValue.get!string); 448 else 449 throw new ConfigFileException( 450 "Got JSON of type " ~ configValue.type.to!string ~ 451 ", expected array or string_.", 452 configKey, 453 configValue, 454 ); 455 } 456 else static if (isAssignable!SymbolType) 457 { 458 __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue); 459 } 460 } 461 462 auto getConfigValue(SymbolType)(string configKey, Json configValue) 463 { 464 import std.conv : to; 465 import std.range.primitives : ElementType; 466 import std.traits : 467 isArray, 468 isDynamicArray, 469 isFloatingPoint, 470 isIntegral, 471 isSomeString, 472 isUnsigned; 473 474 static if (is(SymbolType == OptionFlag)) 475 return configValue.get!bool.to!SymbolType; 476 else static if (is(SymbolType == enum)) 477 return configValue.get!string.to!SymbolType; 478 else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool)) 479 return configValue.get!bool.to!SymbolType; 480 else static if (isFloatingPoint!SymbolType) 481 { 482 if (configValue.type == Json.Type.int_) 483 return configValue.get!long.to!SymbolType; 484 else if (configValue.type == Json.Type.float_) 485 return configValue.get!double.to!SymbolType; 486 else 487 throw new ConfigFileException( 488 "Got JSON of type " ~ configValue.type.to!string ~ 489 ", expected int_ or float_.", 490 configKey, 491 configValue, 492 ); 493 } 494 else static if (isIntegral!SymbolType && isUnsigned!SymbolType) 495 return configValue.get!ulong.to!SymbolType; 496 else static if (isIntegral!SymbolType && !isUnsigned!SymbolType) 497 return configValue.get!long.to!SymbolType; 498 else static if (isSomeString!SymbolType) 499 { 500 if (configValue.type == Json.Type..string) 501 return configValue.get!string.to!SymbolType; 502 else if (configValue.type == Json.Type.null_) 503 return null; 504 else 505 throw new ConfigFileException( 506 "Got JSON of type " ~ configValue.type.to!string ~ 507 ", expected string or null_.", 508 configKey, 509 configValue, 510 ); 511 } 512 else static if (isArray!SymbolType) 513 { 514 SymbolType value; 515 516 static if (isDynamicArray!SymbolType) 517 value.length = configValue.length; 518 else 519 enforce( 520 configValue.length == value.length, 521 "array must have " ~ value.length ~ " elements", 522 configKey, 523 configValue, 524 ); 525 526 foreach (size_t i, configElement; configValue.get!(Json[])) 527 value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement); 528 529 return value; 530 } 531 } 532 533 string readConfigFile(in string configFileName) 534 { 535 import std.stdio : File; 536 import std.format : format; 537 538 auto configFile = File(configFileName, "r"); 539 auto configFileSize = configFile.size; 540 541 enforce( 542 configFileSize <= maxConfigSize, 543 format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand), 544 ); 545 546 auto configContent = configFile.rawRead(new char[configFileSize]); 547 548 return cast(string) configContent; 549 } 550 551 /// Units for bytes. 552 enum SizeUnit 553 { 554 B, 555 KiB, 556 MiB, 557 GiB, 558 TiB, 559 PiB, 560 EiB, 561 ZiB, 562 YiB, 563 } 564 565 /// Convert a value and unit to number of bytes. 566 auto toBytes(in size_t value, in SizeUnit unit) 567 { 568 return value * sizeUnitBase^^unit; 569 } 570 571 /// Convert bytes to 572 auto fromBytes(in size_t bytes) 573 { 574 import std.conv : to; 575 import std.typecons : tuple; 576 import std.traits : EnumMembers; 577 578 alias convertToUnit = exp => tuple!("value", "unit")( 579 bytes.to!double / (sizeUnitBase^^exp), 580 exp, 581 ); 582 583 foreach (exp; EnumMembers!SizeUnit) 584 { 585 if (bytes <= sizeUnitBase^^exp) 586 return convertToUnit(exp); 587 } 588 589 return convertToUnit(SizeUnit.max); 590 } 591 592 private enum size_t sizeUnitBase = 2^^10;