1 /** 2 * User input validation. 3 * 4 * This module contains core functionality for veryfing values as well as some 5 * basic validators. 6 * 7 * Options and arguments (no flags, as they cannot take value) can have any number of validators attached. 8 * Validators are objects that verify user input after parsing values - every validator is run once 9 * per option/argument that it is attached to with complete vector of user input (e.g. for repeating values). 10 * 11 * Validators are ran in order they are defined aborting on first failure with `ValidationException`. 12 * Exception contains the validator that caused the exception (optionally) along with error message. 13 * 14 * Validators can be attached using `validate` method, or using helper methods in form of `accepts*`: 15 * 16 * --- 17 * new Program("test") 18 * .add(new Option("t", "test", "description") 19 * .acceptsValues(["test", "bar"]) 20 * ) 21 * // both are equivalent 22 * .add(new Option("T", "test2", "description") 23 * .validate(new EnumValidator(["test", "bar"])) 24 * ) 25 * --- 26 * 27 * ## Custom Validators 28 * 29 * To add custom validating logic, you can either create custom Validator class that implements `IValidator` interface 30 * or use `DelegateValidator` passing delegate with your validating logic: 31 * 32 * --- 33 * new Program("test") 34 * .add(new Option("t", "test", "description") 35 * .validateWith((entry, value) { 36 * if (value == "test") throw new ValidationException("Value must be test"); 37 * }) 38 * .validateWith(arg => isNumericString(arg), "must be numeric") 39 * ) 40 * --- 41 * 42 * Validators already provided: 43 * - `EnumValidator` (`acceptsValues`) 44 * - `FileSystemValidator` (`acceptsFiles`, `acceptsDirectories`) 45 * - `DelegateValidator` (`validateWith`, `validateEachWith`) 46 * 47 * See_Also: 48 * IValidator, ValidationException 49 */ 50 module commandr.validators; 51 52 import commandr.option : IEntry, InvalidArgumentsException; 53 import commandr.utils : getEntryKindName, matchingCandidate; 54 import std.algorithm : canFind, any, each; 55 import std.array : join; 56 import std..string : format; 57 import std.file : exists, isDir, isFile; 58 import std.typecons : Nullable; 59 60 61 /** 62 * Validation error. 63 * 64 * This exception is thrown when an invalid value has been passed to an option/value 65 * that has validators assigned (manually or through accepts* functions). 66 * 67 * Exception is thrown on first validator failure. Validators are run in definition order. 68 * 69 * Because this exception extends `InvalidArgumentsException`, there's no need to 70 * catch it explicitly unless needed. 71 */ 72 public class ValidationException: InvalidArgumentsException { 73 /** 74 * Validator that caused the error. 75 */ 76 IValidator validator; 77 78 /// Creates new instance of ValidationException 79 public this(IValidator validator, string msg) nothrow pure @safe @nogc { 80 super(msg); 81 this.validator = validator; 82 } 83 } 84 85 /** 86 * Interface for validators. 87 */ 88 public interface IValidator { 89 /** 90 * Checks whenever specified input is valid. 91 * 92 * Params: 93 * entry - Information about checked entry. 94 * values - Array of values to validate. 95 * 96 * Throws: 97 * ValidationException 98 */ 99 void validate(IEntry entry, string[] values); 100 } 101 102 103 /** 104 * Input whitelist check. 105 * 106 * Validates whenever input is contained in list of valid/accepted values. 107 * 108 * Examples: 109 * --- 110 * new Program("test") 111 * .add(new Option("s", "scope", "working scope") 112 * .acceptsValues(["user", "system"]) 113 * ) 114 * --- 115 * 116 * See_Also: 117 * acceptsValues 118 */ 119 public class EnumValidator: IValidator { 120 // TODO: Throw InvalidValidatorException: InvalidProgramException on empty matches? 121 /** 122 * List of allowed values. 123 */ 124 string[] allowedValues; 125 126 /// Creates new instance of EnumValidator 127 public this(string[] values) nothrow pure @safe @nogc { 128 this.allowedValues = values; 129 } 130 131 /// Validates input 132 public void validate(IEntry entry, string[] args) @safe { 133 foreach(arg; args) { 134 if (!allowedValues.canFind(arg)) { 135 string suggestion = allowedValues.matchingCandidate(arg); 136 if (suggestion) { 137 suggestion = " (did you mean %s?)".format(suggestion); 138 } else { 139 suggestion = ""; 140 } 141 142 throw new ValidationException(this, 143 "%s %s must be one of following values: %s%s".format( 144 entry.getEntryKindName(), entry.name, allowedValues.join(", "), suggestion 145 ) 146 ); 147 } 148 } 149 } 150 } 151 152 /** 153 * Helper function to define allowed values for an option or argument. 154 * 155 * This function is meant to be used with UFCS, so that it can be placed 156 * within option definition chain. 157 * 158 * Params: 159 * entry - entry to define allowed values to 160 * values - list of allowed values 161 * 162 * Examples: 163 * --- 164 * new Program("test") 165 * .add(new Option("s", "scope", "working scope") 166 * .acceptsValues(["user", "system"]) 167 * ) 168 * --- 169 * 170 * See_Also: 171 * EnumValidator 172 */ 173 public T acceptsValues(T : IEntry)(T entry, string[] values) @safe { 174 return entry.validate(new EnumValidator(values)); 175 } 176 177 178 /** 179 * Specified expected entry type for `FileSystemValidator`. 180 */ 181 public enum FileType { 182 /// 183 Directory, 184 185 /// 186 File 187 } 188 189 190 /** 191 * FileSystem validator. 192 * 193 * See_Also: 194 * acceptsFiles, acceptsDirectories, acceptsPath 195 */ 196 public class FileSystemValidator: IValidator { 197 /// Exists contraint 198 Nullable!bool exists; 199 200 /// Entry type contraint 201 Nullable!FileType type; 202 203 /** 204 * Creates new FileSystem validator. 205 * 206 * This constructor creates a `FileSystemValidator` that checks only 207 * whenever the path points to a existing (or not) item. 208 * 209 * Params: 210 * exists - Whenever passed path should exist. 211 */ 212 public this(bool exists) nothrow pure @safe @nogc { 213 this.exists = exists; 214 } 215 216 /** 217 * Creates new FileSystem validator. 218 * 219 * This constructor creates a `FileSystemValidator` that checks 220 * whenever the path points to a existing item of specified type. 221 * 222 * Params: 223 * type - Expected item type 224 */ 225 public this(FileType type) { 226 this.exists = true; 227 this.type = type; 228 } 229 230 /// Validates input 231 public void validate(IEntry entry, string[] args) { 232 foreach (arg; args) { 233 if (!this.exists.isNull) { 234 validateExists(entry, arg, this.exists.get()); 235 } 236 237 if (!this.type.isNull) { 238 validateType(entry, arg, this.type.get()); 239 } 240 } 241 } 242 243 private void validateExists(IEntry entry, string arg, bool exists) { 244 if (arg.exists() == exists) { 245 return; 246 } 247 248 throw new ValidationException(this, 249 "%s %s value must point to a %s that %sexists".format( 250 entry.getEntryKindName(), 251 entry.name, 252 this.type.isNull 253 ? "file/directory" 254 : this.type.get() == FileType.Directory ? "directory" : "file", 255 exists ? "" : "not " 256 ) 257 ); 258 } 259 260 private void validateType(IEntry entry, string arg, FileType type) { 261 switch (type) { 262 case FileType.File: 263 if (!arg.isFile) { 264 throw new ValidationException(this, 265 "value specified in %s %s must be a valid file".format( 266 entry.getEntryKindName(), entry.name, 267 ) 268 ); 269 } 270 break; 271 272 case FileType.Directory: 273 if (!arg.isDir) { 274 throw new ValidationException(this, 275 "value specified in %s %s must be a valid file".format( 276 entry.getEntryKindName(), entry.name, 277 ) 278 ); 279 } 280 break; 281 282 default: 283 assert(0); 284 } 285 } 286 } 287 288 /** 289 * Helper function to require passing a path pointing to existing file. 290 * 291 * This function is meant to be used with UFCS, so that it can be placed 292 * within option definition chain. 293 * 294 * Examples: 295 * --- 296 * new Program("test") 297 * .add(new Option("c", "config", "path to config file") 298 * .accpetsFiles() 299 * ) 300 * --- 301 * 302 * See_Also: 303 * FileSystemValidator, acceptsDirectories, acceptsPaths 304 */ 305 public T acceptsFiles(T: IEntry)(T entry) { 306 return entry.validate(new FileSystemValidator(FileType.File)); 307 } 308 309 310 /** 311 * Helper function to require passing a path pointing to existing directory. 312 * 313 * This function is meant to be used with UFCS, so that it can be placed 314 * within option definition chain. 315 * 316 * Examples: 317 * --- 318 * new Program("ls") 319 * .add(new Argument("directory", "directory to list") 320 * .acceptsDirectories() 321 * ) 322 * --- 323 * 324 * See_Also: 325 * FileSystemValidator, acceptsFiles, acceptsPaths 326 */ 327 public T acceptsDirectories(T: IEntry)(T entry) { 328 return entry.validate(new FileSystemValidator(FileType.Directory)); 329 } 330 331 332 /** 333 * Helper function to require passing a path pointing to existing file or directory. 334 * 335 * This function is meant to be used with UFCS, so that it can be placed 336 * within option definition chain. 337 * 338 * Params: 339 * existing - whenever path target must exist 340 * 341 * Examples: 342 * --- 343 * new Program("rm") 344 * .add(new Argument("target", "target to remove") 345 * .acceptsPaths(true) 346 * ) 347 * --- 348 * 349 * See_Also: 350 * FileSystemValidator, acceptsDirectories, acceptsFiles 351 */ 352 public T acceptsPaths(T: IEntry)(T entry, bool existing) { 353 return entry.validate(new FileSystemValidator(existing)); 354 } 355 356 /** 357 * Validates input based on delegate. 358 * 359 * Delegate receives all arguments that `IValidator.validate` receives, that is 360 * information about entry being checked and an array of values to perform check on. 361 * 362 * For less verbose usage, check `validateWith` and `validateEachWith` helper functions. 363 * 364 * Examples: 365 * --- 366 * new Program("rm") 367 * .add(new Argument("target", "target to remove") 368 * .validate(new DelegateValidator((entry, args) { 369 * foreach (arg; args) { 370 * if (arg == "foo") throw new ValidationException("invalid number"); // would throw with "invalid number" 371 * } 372 * })) 373 * // or 374 * .validateEachWith((entry, arg) { 375 * if (arg == "5") throw new ValidationException("invalid number"); // would throw with "invalid number" 376 * }) 377 * // or 378 * .validateEachWith(arg => isGood(arg), "must be good") // would throw with "flag a must be good" 379 * ) 380 * --- 381 * 382 * See_Also: 383 * validateWith, validateEachWith 384 */ 385 public class DelegateValidator : IValidator { 386 /// Validator function type 387 alias ValidatorFunc = void delegate(IEntry, string[]); 388 389 /// Validator function 390 ValidatorFunc validator; 391 392 /// Creates instance of DelegateValidator 393 public this(ValidatorFunc validator) nothrow pure @safe @nogc { 394 this.validator = validator; 395 } 396 397 /// Validates input 398 public void validate(IEntry entry, string[] args) { 399 this.validator(entry, args); 400 } 401 } 402 403 /** 404 * Helper function to add custom validating delegate. 405 * 406 * This function is meant to be used with UFCS, so that it can be placed 407 * within option definition chain. 408 * 409 * In contrast with `validateEachWith`, this functions makes a single call to delegate with all values. 410 * 411 * Params: 412 * validator - delegate performing validation of all values 413 * 414 * Examples: 415 * --- 416 * new Program("rm") 417 * .add(new Argument("target", "target to remove") 418 * .validateWith((entry, args) { 419 * foreach (arg; args) { 420 * // do something 421 * } 422 * }) 423 * ) 424 * --- 425 * 426 * See_Also: 427 * DelegateValidator, validateEachWith 428 */ 429 public T validateWith(T: IEntry)(T entry, DelegateValidator.ValidatorFunc validator) { 430 return entry.validate(new DelegateValidator(validator)); 431 } 432 433 434 /** 435 * Helper function to add custom validating delegate. 436 * 437 * This function is meant to be used with UFCS, so that it can be placed 438 * within option definition chain. 439 * 440 * In contrast with `validateWith`, this functions makes call to delegate for every value. 441 * 442 * Params: 443 * validator - delegate performing validation of single value 444 * 445 * Examples: 446 * --- 447 * new Program("rm") 448 * .add(new Argument("target", "target to remove") 449 * .validateEachWith((entry, arg) { 450 * // do something 451 * }) 452 * ) 453 * --- 454 * 455 * See_Also: 456 * DelegateValidator, validateWith 457 */ 458 public T validateEachWith(T: IEntry)(T entry, void delegate(IEntry, string) validator) { 459 return validateWith!T(entry, (e, args) { args.each!(a => validator(e, a)); }); 460 } 461 462 463 /** 464 * Helper function to add custom validating delegate. 465 * 466 * This function is meant to be used with UFCS, so that it can be placed 467 * within option definition chain. 468 * 469 * This function automatically prepends entry information to your error message, 470 * so that call to `new Option("", "foo", "").validateWith(a => a.isDir, "must be a directory")` 471 * on failure would throw `ValidationException` with message `option foo must be a directory`. 472 * 473 * Params: 474 * validator - delegate performing validation, returning true on success 475 * message - error message 476 * 477 * Examples: 478 * --- 479 * new Program("rm") 480 * .add(new Argument("target", "target to remove") 481 * .validateEachWith(arg => arg.isSymLink, "must be a symlink") 482 * ) 483 * --- 484 * 485 * See_Also: 486 * DelegateValidator, validateWith 487 */ 488 public T validateEachWith(T: IEntry)(T entry, bool delegate(string) validator, string errorMessage) { 489 return entry.validateEachWith((entry, arg) { 490 if (!validator(arg)) { 491 throw new ValidationException(null, "%s %s %s".format(entry.getEntryKindName(), entry.name, errorMessage)); 492 } 493 }); 494 } 495 496 // enum 497 unittest { 498 import commandr.program; 499 import commandr.option; 500 import commandr.parser; 501 import std.exception : assertThrown, assertNotThrown; 502 503 assertNotThrown!ValidationException( 504 new Program("test") 505 .add(new Option("t", "type", "foo") 506 .acceptsValues(["a", "b"]) 507 ) 508 .parseArgsNoRef(["test"]) 509 ); 510 511 assertNotThrown!ValidationException( 512 new Program("test") 513 .add(new Option("t", "type", "foo") 514 .acceptsValues(["a", "b"]) 515 ) 516 .parseArgsNoRef(["test", "--type", "a"]) 517 ); 518 519 assertNotThrown!ValidationException( 520 new Program("test") 521 .add(new Option("t", "type", "foo") 522 .acceptsValues(["a", "b"]) 523 .repeating 524 ) 525 .parseArgsNoRef(["test", "--type", "a", "--type", "b"]) 526 ); 527 528 assertThrown!ValidationException( 529 new Program("test") 530 .add(new Option("t", "type", "foo") 531 .acceptsValues(["a", "b"]) 532 .repeating 533 ) 534 .parseArgsNoRef(["test", "--type", "c", "--type", "b"]) 535 ); 536 537 assertThrown!ValidationException( 538 new Program("test") 539 .add(new Option("t", "type", "foo") 540 .acceptsValues(["a", "b"]) 541 .repeating 542 ) 543 .parseArgsNoRef(["test", "--type", "a", "--type", "z"]) 544 ); 545 } 546 547 // delegate 548 unittest { 549 import commandr.program; 550 import commandr.option; 551 import commandr.parser; 552 import std.exception : assertThrown, assertNotThrown; 553 import std..string : isNumeric; 554 555 assertNotThrown!ValidationException( 556 new Program("test") 557 .add(new Option("t", "type", "foo") 558 .validateEachWith(a => isNumeric(a), "must be an integer") 559 ) 560 .parseArgsNoRef(["test", "--type", "50"]) 561 ); 562 563 assertThrown!ValidationException( 564 new Program("test") 565 .add(new Option("t", "type", "foo") 566 .validateEachWith(a => isNumeric(a), "must be an integer") 567 ) 568 .parseArgsNoRef(["test", "--type", "a"]) 569 ); 570 }