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 file = The file where the exception occurred. 121 line = The line number where the exception occurred. 122 next = The previous exception in the chain of exceptions, if any. 123 */ 124 this(string msg, string file = __FILE__, size_t line = __LINE__, 125 Throwable next = null) @nogc @safe pure nothrow 126 { 127 super(msg, file, line, next); 128 } 129 130 /** 131 Params: 132 msg = The message for the exception. 133 next = The previous exception in the chain of exceptions. 134 file = The file where the exception occurred. 135 line = The line number where the exception occurred. 136 */ 137 this(string msg, Throwable next, string file = __FILE__, 138 size_t line = __LINE__) @nogc @safe pure nothrow 139 { 140 super(msg, file, line, next); 141 } 142 143 /** 144 Params: 145 msg = The message for the exception. 146 configKey = Key of the erroneous config entry. 147 configValue = Value of the erroneous config entry. 148 file = The file where the exception occurred. 149 line = The line number where the exception occurred. 150 next = The previous exception in the chain of exceptions, if any. 151 */ 152 this(string msg, string configKey, Json configValue, string file = __FILE__, size_t line = __LINE__, 153 Throwable next = null) @nogc @safe pure nothrow 154 { 155 super(msg, file, line, next); 156 this.configKey = configKey; 157 this.configValue = configValue; 158 } 159 160 /** 161 Params: 162 msg = The message for the exception. 163 configKey = Key of the erroneous config entry. 164 configValue = Value of the erroneous config entry. 165 next = The previous exception in the chain of exceptions. 166 file = The file where the exception occurred. 167 line = The line number where the exception occurred. 168 */ 169 this(string msg, string configKey, Json configValue, Throwable next, string file = __FILE__, 170 size_t line = __LINE__) @nogc @safe pure nothrow 171 { 172 super(msg, file, line, next); 173 this.configKey = configKey; 174 this.configValue = configValue; 175 } 176 } 177 178 T enforce(T)( 179 T value, 180 lazy string message, 181 lazy string configKey = null, 182 lazy Json configValue = Json.init, 183 string file = __FILE__, 184 size_t line = __LINE__, 185 ) 186 { 187 static import std.exception; 188 189 return std.exception.enforce(value, new ConfigFileException( 190 message, 191 configKey, 192 configValue, 193 file, 194 line, 195 )); 196 } 197 198 199 /// Retroactively initialize options from config. 200 Options retroInitFromConfig(Options)(ref Options options, in string configFile) 201 { 202 return retroInitFromConfig(options, parseConfig(configFile)); 203 } 204 205 Options retroInitFromConfig(Options)(ref Options options, in Json config) 206 { 207 import std.format : format; 208 import std.meta : Alias; 209 import std.traits : 210 getUDAs, 211 isArray, 212 isFloatingPoint, 213 isSomeString, 214 isSomeString, 215 isStaticArray; 216 217 enum defaultOptions = Options.init; 218 Options optionsFromConfig = parseConfig!Options(config); 219 220 static foreach (member; __traits(allMembers, Options)) 221 {{ 222 alias symbol = Alias!(__traits(getMember, options, member)); 223 enum isMemberAssignable = __traits(compiles, 224 __traits(getMember, options, member) = __traits(getMember, options, member) 225 ); 226 227 static if (isMemberAssignable) 228 { 229 alias Member = typeof(__traits(getMember, options, member)); 230 enum unaryMixin(string template_) = format!template_(member); 231 enum binaryMixin(string template_) = format!template_(member, member); 232 alias assignConfigValue = () => mixin(binaryMixin!"options.%s = optionsFromConfig.%s"); 233 234 static if (getUDAs!(symbol, Argument).length > 0) 235 { 236 static if (isSomeString!Member) 237 { 238 if (mixin(unaryMixin!"options.%s == configEmptyArgument")) 239 assignConfigValue(); 240 } 241 else static if (isArray!Member && isSomeString!(ElementType!Member)) 242 { 243 if (mixin(unaryMixin!"options.%s.all!(v => v == configEmptyArgument)")) 244 assignConfigValue(); 245 } 246 } 247 else 248 { 249 static if (isStaticArray!Member || is(Member == class)) 250 { 251 if (mixin(binaryMixin!"options.%s == defaultOptions.%s")) 252 assignConfigValue(); 253 } 254 else static if (isFloatingPoint!Member) 255 { 256 if ( 257 mixin(binaryMixin!"options.%s == defaultOptions.%s") || 258 ( 259 mixin(unaryMixin!"options.%s.isNaN") && 260 mixin(unaryMixin!"defaultOptions.%s.isNaN") 261 ) 262 ) 263 assignConfigValue(); 264 } 265 else 266 { 267 if (mixin(binaryMixin!"options.%s is defaultOptions.%s")) 268 assignConfigValue(); 269 } 270 } 271 } 272 }} 273 274 return options; 275 } 276 277 278 /// Initialize options using config. 279 Options parseConfig(Options)(in string configFile) 280 { 281 auto configContent = readConfigFile(configFile); 282 auto configValues = parseJson( 283 configContent, 284 null, 285 configFile, 286 ); 287 288 return parseConfig!Options(configValues); 289 } 290 291 /// ditto 292 Options parseConfig(Options)(in Json config) 293 { 294 import std.meta : Alias; 295 296 validateConfig!Options(config); 297 298 Options options; 299 300 foreach (member; __traits(allMembers, Options)) 301 { 302 alias symbol = Alias!(__traits(getMember, options, member)); 303 enum names = configNamesOf!symbol; 304 305 static if (names.length > 0) 306 { 307 foreach (name; names) 308 if (name in config) 309 options.assignConfigValue!member(name, config[name]); 310 } 311 } 312 313 return options; 314 } 315 316 /// Validate config. 317 void validateConfigFile(Options)(in string configFile) 318 { 319 import vibe.data.json : parseJson; 320 321 auto configContent = readConfigFile(configFile); 322 auto configValues = parseJson( 323 configContent, 324 null, 325 configFile, 326 ); 327 328 validateConfig!Options(configValues); 329 } 330 331 /// ditto 332 void validateConfig(Options)(in Json config) 333 { 334 import std.algorithm : startsWith; 335 import std.format : format; 336 import std.meta : Alias; 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.traits : isAssignable; 419 420 alias SymbolType = typeof(__traits(getMember, options, member)); 421 422 static if (isOptionHandler!SymbolType) 423 { 424 if (configValue.type == Json.Type.int_) 425 foreach (i; 0 .. configValue.get!ulong) 426 __traits(getMember, options, member)(); 427 else if (configValue.type == Json.Type.bool_) 428 { 429 if (configValue.get!bool) 430 __traits(getMember, options, member)(); 431 } 432 else 433 throw new ConfigFileException( 434 "Got JSON of type " ~ configValue.type.to!string ~ 435 ", expected int_ or bool_.", 436 configKey, 437 configValue, 438 ); 439 } 440 else static if (isArgumentHandler!SymbolType) 441 { 442 if (configValue.type == Json.Type.array) 443 foreach (item; configValue.get!(Json[])) 444 __traits(getMember, options, member)(item.get!string); 445 else if (configValue.type == Json.Type..string) 446 __traits(getMember, options, member)(configValue.get!string); 447 else 448 throw new ConfigFileException( 449 "Got JSON of type " ~ configValue.type.to!string ~ 450 ", expected array or string_.", 451 configKey, 452 configValue, 453 ); 454 } 455 else static if (isAssignable!SymbolType) 456 { 457 __traits(getMember, options, member) = getConfigValue!SymbolType(configKey, configValue); 458 } 459 } 460 461 auto getConfigValue(SymbolType)(string configKey, Json configValue) 462 { 463 import std.conv : to; 464 import std.range.primitives : ElementType; 465 import std.traits : 466 isArray, 467 isDynamicArray, 468 isFloatingPoint, 469 isIntegral, 470 isSomeString, 471 isUnsigned; 472 473 static if (is(SymbolType == OptionFlag)) 474 return configValue.get!bool.to!SymbolType; 475 else static if (is(SymbolType == enum)) 476 return configValue.get!string.to!SymbolType; 477 else static if (is(SymbolType == OptionFlag) || is(SymbolType : bool)) 478 return configValue.get!bool.to!SymbolType; 479 else static if (isFloatingPoint!SymbolType) 480 return configValue.get!double.to!SymbolType; 481 else static if (isIntegral!SymbolType && isUnsigned!SymbolType) 482 return configValue.get!ulong.to!SymbolType; 483 else static if (isIntegral!SymbolType && !isUnsigned!SymbolType) 484 return configValue.get!long.to!SymbolType; 485 else static if (isSomeString!SymbolType) 486 { 487 if (configValue.type == Json.Type..string) 488 return configValue.get!string.to!SymbolType; 489 else if (configValue.type == Json.Type.null_) 490 return null; 491 else 492 throw new ConfigFileException( 493 "Got JSON of type " ~ configValue.type.to!string ~ 494 ", expected string or null_.", 495 configKey, 496 configValue, 497 ); 498 } 499 else static if (isArray!SymbolType) 500 { 501 SymbolType value; 502 503 static if (isDynamicArray!SymbolType) 504 value.length = configValue.length; 505 else 506 enforce( 507 configValue.length == value.length, 508 "array must have " ~ value.length ~ " elements", 509 configKey, 510 configValue, 511 ); 512 513 foreach (size_t i, configElement; configValue.get!(Json[])) 514 value[i] = getConfigValue!(ElementType!SymbolType)(configKey, configElement); 515 516 return value; 517 } 518 } 519 520 string readConfigFile(in string configFileName) 521 { 522 import std.stdio : File; 523 import std.format : format; 524 525 auto configFile = File(configFileName, "r"); 526 auto configFileSize = configFile.size; 527 528 enforce( 529 configFileSize <= maxConfigSize, 530 format!"config file is too large; must be <= %.2f %s"(fromBytes(maxConfigSize).expand), 531 ); 532 533 auto configContent = configFile.rawRead(new char[configFileSize]); 534 535 return cast(string) configContent; 536 } 537 538 /// Units for bytes. 539 enum SizeUnit 540 { 541 B, 542 KiB, 543 MiB, 544 GiB, 545 TiB, 546 PiB, 547 EiB, 548 ZiB, 549 YiB, 550 } 551 552 /// Convert a value and unit to number of bytes. 553 auto toBytes(in size_t value, in SizeUnit unit) 554 { 555 return value * sizeUnitBase^^unit; 556 } 557 558 /// Convert bytes to 559 auto fromBytes(in size_t bytes) 560 { 561 import std.conv : to; 562 import std.typecons : tuple; 563 import std.traits : EnumMembers; 564 565 alias convertToUnit = exp => tuple!("value", "unit")( 566 bytes.to!double / (sizeUnitBase^^exp), 567 exp, 568 ); 569 570 foreach (exp; EnumMembers!SizeUnit) 571 { 572 if (bytes <= sizeUnitBase^^exp) 573 return convertToUnit(exp); 574 } 575 576 return convertToUnit(SizeUnit.max); 577 } 578 579 private enum size_t sizeUnitBase = 2^^10;