Repository: clap-rs/clap (rust)

Issue:
Validate argument with regex
### Describe your use case

It would be nice to have a clap feature for argument validation via a regex. This would be handy and flexible for users, allowing them custom validations without writing a [custom validator](https://github.com/clap-rs/clap/blob/master/examples/15_custom_validator.rs) to do so, and hopefully without requiring too large of overhead for implementation and testing in clap itself.

### Describe the solution you'd like

For example, using clap yaml format, if we want an argument whose value wants only digit values 1 through 100:

```
args:
  - jpg-quality:
    about: Quality of jpg image to write. Must be a number 1-100.
    takes_value: true
    regex: "^[1-9][0-9]?$|^100$"
```

This could expand to complex regex use cases, like matching if an arg value is a URL:

```
args:
  - url:
    about: URL to load.
    index: 1
    required: true
    # Regex stolen from: https://stackoverflow.com/a/3809435
    regex: "[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)"
```

Thanks for this very nice arg parsing library!

Pull request description:
Add new "regex" feature
This draft introduces a new feature called `"regex"`. It adds a new function `validator_regex` to `Arg` and was inspired by the discussion in #1968. The name `validator_regex` was chosen instead of `regex_validator` to make sure that the developer keeps in mind that there may only be a single `Validator` on an `Arg`.

The feature can be used with YAML files, however there is no proper pattern in `clap_app!` (yet). The proposed solution differs from #1968 in some places:

- Since `validator_regex` doesn't use `&str` in its first argument, we cannot use `yaml_tuple2!`. That's a little bit unfortunate, but the resulting error messages should be slightly more helpful to the user.
- The function takes a `Regex` instead of a `&str` as argument to make sure that we have a valid regular expression at hand

While the feature works-as-is, there are some open questions that need to get addressed before a merge:

- Should `validator_regex` borrow the `Regex` instead of taking ownership? That might be difficult as `regex` in the YAML handling does not live long enough. (Answer: `Cow`-like type)
- Should `validator_regex` take a `&str` instead of a `Regex`? I'm not sure how to report any errors in `Regex::new` in a way that's compatible to the usual `fn something(...) -> Self` functions in `Arg`. We could use `Result<Self, Regex::Error>` as a return type instead, but that breaks `Arg`s interface for this function. (Answer: `Cow`-like type)
- Should there be a new `ErrorKind` for regex related errors? (Answer: stays `ValueValidation`)
- Should the YAML key be `validator_regex` (i.e. the same name as the function) or just `regex`? (Answer: `validator_regex`)
Closes #1968


Patch:
diff --git a/Cargo.toml b/Cargo.toml
index a6af6d41..9daf4d0b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ termcolor   = { version = "1.1", optional = true }
 terminal_size = { version = "0.1.12", optional = true }
 lazy_static = { version = "1", optional = true }
 clap_derive = { path = "./clap_derive", version = "3.0.0-beta.1", optional = true }
+regex = { version = "1.0", optional = true }
 
 [dev-dependencies]
 regex = "1.0"
@@ -96,7 +97,7 @@ yaml        = ["yaml-rust"]
 cargo       = ["lazy_static"] # Disable if you're not using Cargo, enables Cargo-env-var-dependent macros
 unstable    = ["clap_derive/unstable"] # for building with unstable clap features (doesn't require nightly Rust) (currently none)
 debug       = ["clap_derive/debug"] # Enables debug messages
-doc         = ["yaml"] # All the features which add to documentation
+doc         = ["yaml", "regex"] # All the features which add to documentation
 
 [profile.test]
 opt-level = 1
diff --git a/src/build/arg/mod.rs b/src/build/arg/mod.rs
index 59b2e2b6..b77d7fc6 100644
--- a/src/build/arg/mod.rs
+++ b/src/build/arg/mod.rs
@@ -18,6 +18,14 @@ use std::{
 };
 
 // Third Party
+#[cfg(feature = "regex")]
+use ::regex::Regex;
+
+#[cfg(feature = "regex")]
+mod regex;
+
+#[cfg(feature = "regex")]
+pub use self::regex::RegexRef;
 
 // Internal
 use crate::{
@@ -1917,6 +1925,78 @@ impl<'help> Arg<'help> {
         self
     }
 
+    /// Validates the argument via the given regular expression.
+    ///
+    /// As regular expressions are not very user friendly, the additional `err_message` should
+    /// describe the expected format in clear words. All notes for [`Arg::validator()`] regarding the
+    /// error message and performance also hold for `validator_regex`.
+    ///
+    /// The regular expression can either be borrowed or moved into `validator_regex`. This happens
+    /// automatically via [`RegexRef`]'s `Into` implementation.
+    ///
+    /// **NOTE:** If using YAML then a single vector with two entries should be provided:
+    /// ```yaml
+    /// validator_regex: [remove-all-files, needs the exact phrase 'remove-all-files' to continue]
+    /// ```
+    ///
+    /// # Performance
+    /// Regular expressions are expensive to compile. You should prefer sharing your regular expression.
+    /// We use a [`Cow`]-like internal structure to enable both sharing as well as taking ownership of a
+    /// provided regular expression.
+    ///
+    /// # Examples
+    /// You can use the classical `"\d+"` regular expression to match digits only:
+    /// ```rust
+    /// # use clap::{App, Arg};
+    /// use regex::Regex;
+    ///
+    /// let digits = Regex::new(r"\d+").unwrap();
+    ///
+    /// let res = App::new("prog")
+    ///     .arg(Arg::new("digits")
+    ///         .index(1)
+    ///         .validator_regex(&digits, "only digits are allowed"))
+    ///     .try_get_matches_from(vec![
+    ///         "prog", "12345"
+    ///     ]);
+    /// assert!(res.is_ok());
+    /// assert_eq!(res.unwrap().value_of("digits"), Some("12345"));
+    /// ```
+    /// However, any valid `Regex` can be used:
+    /// ```rust
+    /// # use clap::{App, Arg, ErrorKind};
+    /// use regex::Regex;
+    ///
+    /// let priority = Regex::new(r"[A-C]").unwrap();
+    ///
+    /// let res = App::new("prog")
+    ///     .arg(Arg::new("priority")
+    ///         .index(1)
+    ///         .validator_regex(priority, "only priorities A, B or C are allowed"))
+    ///     .try_get_matches_from(vec![
+    ///         "prog", "12345"
+    ///     ]);
+    /// assert!(res.is_err());
+    /// assert_eq!(res.err().unwrap().kind, ErrorKind::ValueValidation)
+    /// ```
+    /// [`Arg::validator()`]: ./struct.Arg.html#method.validator
+    /// [`RegexRef`]: ./struct.RegexRef.html
+    #[cfg(feature = "regex")]
+    pub fn validator_regex(
+        self,
+        regex: impl Into<RegexRef<'help>>,
+        err_message: &'help str,
+    ) -> Self {
+        let regex = regex.into();
+        self.validator(move |s: &str| {
+            if regex.is_match(s) {
+                Ok(())
+            } else {
+                Err(err_message)
+            }
+        })
+    }
+
     /// Specifies the *maximum* number of values are for this argument. For example, if you had a
     /// `-f <file>` argument where you wanted up to 3 'files' you would set `.max_values(3)`, and
     /// this argument would be satisfied if the user provided, 1, 2, or 3 values.
@@ -4326,6 +4406,23 @@ impl<'help> From<&'help Yaml> for Arg<'help> {
                     a.set_mut(ArgSettings::RequiredUnlessAll);
                     a
                 }
+                #[cfg(feature = "regex")]
+                "validator_regex" => {
+                    if let Some(vec) = v.as_vec() {
+                        debug_assert_eq!(2, vec.len());
+                        let regex = yaml_str!(vec[0]);
+
+                        match Regex::new(regex) {
+                            Err(e) => panic!(
+                                "Failed to convert \"{}\" into regular expression: {}",
+                                regex, e
+                            ),
+                            Ok(regex) => a.validator_regex(regex, yaml_str!(vec[1])),
+                        }
+                    } else {
+                        panic!("Failed to convert YAML value to vector")
+                    }
+                }
                 s => panic!(
                     "Unknown Arg setting '{}' in YAML file for arg '{}'",
                     s, name_str
diff --git a/src/build/arg/regex.rs b/src/build/arg/regex.rs
new file mode 100644
index 00000000..54ad0490
--- /dev/null
+++ b/src/build/arg/regex.rs
@@ -0,0 +1,71 @@
+use ::regex::Regex;
+use core::convert::TryFrom;
+use core::ops::Deref;
+use core::str::FromStr;
+use std::borrow::Cow;
+
+/// Contains either a regular expression or a reference to one.
+///
+/// Essentially a [`Cow`] wrapper with custom convenience traits.
+///
+/// [`Cow`]: https://doc.rust-lang.org/std/borrow/enum.Cow.html
+#[derive(Debug, Clone)]
+pub struct RegexRef<'a>(Cow<'a, Regex>);
+
+impl<'a> Deref for RegexRef<'a> {
+    type Target = Regex;
+
+    fn deref(&self) -> &Regex {
+        self.0.deref()
+    }
+}
+
+impl<'a> FromStr for RegexRef<'a> {
+    type Err = <Regex as core::str::FromStr>::Err;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        Regex::from_str(s).map(|v| RegexRef(Cow::Owned(v)))
+    }
+}
+
+impl<'a> TryFrom<&'a str> for RegexRef<'a> {
+    type Error = <RegexRef<'a> as FromStr>::Err;
+    fn try_from(r: &'a str) -> Result<Self, Self::Error> {
+        RegexRef::from_str(r)
+    }
+}
+
+impl<'a> From<&'a Regex> for RegexRef<'a> {
+    fn from(r: &'a Regex) -> Self {
+        RegexRef(Cow::Borrowed(r))
+    }
+}
+
+impl<'a> From<Regex> for RegexRef<'a> {
+    fn from(r: Regex) -> Self {
+        RegexRef(Cow::Owned(r))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use core::convert::TryInto;
+    #[test]
+    fn test_try_from_with_valid_string() {
+        let t: Result<RegexRef, _> = "^Hello, World$".try_into();
+        assert!(t.is_ok())
+    }
+
+    #[test]
+    fn test_try_from_with_invalid_string() {
+        let t: Result<RegexRef, _> = "^Hello, World)$".try_into();
+        assert!(t.is_err());
+    }
+
+    #[test]
+    fn from_str() {
+        let t: Result<RegexRef, _> = RegexRef::from_str("^Hello, World");
+        assert!(t.is_ok());
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index b942110e..971d37a9 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -50,6 +50,9 @@ mod macros;
 #[cfg(feature = "derive")]
 mod derive;
 
+#[cfg(feature = "regex")]
+pub use crate::build::arg::RegexRef;
+
 mod build;
 mod mkeymap;
 mod output;


Test patch:
diff --git a/tests/fixtures/app_regex.yaml b/tests/fixtures/app_regex.yaml
new file mode 100644
index 00000000..bed49cfb
--- /dev/null
+++ b/tests/fixtures/app_regex.yaml
@@ -0,0 +1,15 @@
+name: clapregextest
+version: "1.0"
+about: tests clap regex functionality
+author: Benjamin Kästner <benjamin.kaestner@gmail.com>
+settings:
+    - ArgRequiredElseHelp
+args:
+    - help:
+        short: h
+        long: help
+        about: prints help with a nonstandard description
+    - filter:
+        index: 1
+        validator_regex: ["^*\\.[a-z]+$", expected extension pattern]
+        about: file extension pattern
diff --git a/tests/fixtures/app_regex_invalid.yaml b/tests/fixtures/app_regex_invalid.yaml
new file mode 100644
index 00000000..66425cfa
--- /dev/null
+++ b/tests/fixtures/app_regex_invalid.yaml
@@ -0,0 +1,14 @@
+name: clapregextest
+version: "1.0"
+about: tests clap regex functionality
+author: Benjamin Kästner <benjamin.kaestner@gmail.com>
+settings:
+    - ArgRequiredElseHelp
+args:
+    - help:
+        short: h
+        long: help
+        about: prints help with a nonstandard description
+    - filter:
+        index: 1
+        validator_regex: [")", invalid regular expression]
diff --git a/tests/yaml.rs b/tests/yaml.rs
index c18039d3..a9e73c9f 100644
--- a/tests/yaml.rs
+++ b/tests/yaml.rs
@@ -99,3 +99,32 @@ fn default_value_if_triggered_by_flag_and_argument() {
     // First condition triggers, therefore "some"
     assert_eq!(matches.value_of("positional2").unwrap(), "some");
 }
+
+#[cfg(feature = "regex")]
+#[test]
+fn regex_with_invalid_string() {
+    let yml = load_yaml!("fixtures/app_regex.yaml");
+    let app = App::from(yml);
+    let res = app.try_get_matches_from(vec!["prog", "not a proper filter"]);
+
+    assert!(res.is_err());
+}
+
+#[cfg(feature = "regex")]
+#[test]
+fn regex_with_valid_string() {
+    let yml = load_yaml!("fixtures/app_regex.yaml");
+    let app = App::from(yml);
+
+    let matches = app.try_get_matches_from(vec!["prog", "*.txt"]).unwrap();
+
+    assert_eq!(matches.value_of("filter").unwrap(), "*.txt");
+}
+
+#[cfg(feature = "regex")]
+#[test]
+#[should_panic]
+fn regex_with_invalid_yaml() {
+    let yml = load_yaml!("fixtures/app_regex_invalid.yaml");
+    let _app = App::from(yml);
+}