Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework string length rules #86

Closed
wants to merge 16 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
include:
- build: pinned
os: ubuntu-20.04
rust: 1.69
rust: 1.71
# Fails on pinned version because the output changed,
# so we're excluding it, but it's still tested on stable and nightly.
EXCLUDE_UI_TESTS: "pattern_mismatched_types,newtype"
Expand All @@ -88,7 +88,7 @@ jobs:
shared-key: "rust-${{ matrix.build }}-test"

- name: Build xtask
run: cargo build --manifest-path ./xtask/Cargo.toml
run: cargo build --verbose --manifest-path ./xtask/Cargo.toml

- name: Build docs
run: cargo doc --all-features
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/target
/Cargo.lock
/**/Cargo.lock
*.snap.new
77 changes: 41 additions & 36 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,35 +74,37 @@ if let Err(e) = data.validate(&()) {

### Available validation rules

| name | format | validation | feature flag |
| ------------ | ------------------------------------------------ | ---------------------------------------------------- | -------------- |
| required | `#[garde(required)]` | is value set | - |
| ascii | `#[garde(ascii)]` | only contains ASCII | - |
| alphanumeric | `#[garde(alphanumeric)]` | only letters and digits | - |
| email | `#[garde(email)]` | an email according to the HTML5 spec[^1] | `email` |
| url | `#[garde(url)]` | a URL | `url` |
| ip | `#[garde(ip)]` | an IP address (either IPv4 or IPv6) | - |
| ipv4 | `#[garde(ipv4)]` | an IPv4 address | - |
| ipv6 | `#[garde(ipv6)]` | an IPv6 address | - |
| credit card | `#[garde(credit_card)]` | a credit card number | `credit-card` |
| phone number | `#[garde(phone_number)]` | a phone number | `phone-number` |
| length | `#[garde(length(min=<usize>, max=<usize>)]` | a container with length in `min..=max` | - |
| byte_length | `#[garde(byte_length(min=<usize>, max=<usize>)]` | a byte sequence with length in `min..=max` | - |
| range | `#[garde(range(min=<expr>, max=<expr>))]` | a number in the range `min..=max` | - |
| contains | `#[garde(contains(<string>))]` | a string-like value containing a substring | - |
| prefix | `#[garde(prefix(<string>))]` | a string-like value prefixed by some string | - |
| suffix | `#[garde(suffix(<string>))]` | a string-like value suffixed by some string | - |
| pattern | `#[garde(pattern("<regex>"))]` | a string-like value matching some regular expression | `regex` |
| pattern | `#[garde(pattern(<matcher>))]` | a string-like value matched by some [Matcher](https://docs.rs/garde/latest/garde/rules/pattern/trait.Matcher.html) | - |
| dive | `#[garde(dive)]` | nested validation, calls `validate` on the value | - |
| skip | `#[garde(skip)]` | skip validation | - |
| custom | `#[garde(custom(<function or closure>))]` | a custom validator | - |
| name | format | validation | feature flag |
| -------------- | --------------------------------------------------- | ---------------------------------------------------- | -------------- |
| required | `#[garde(required)]` | is value set | - |
| ascii | `#[garde(ascii)]` | only contains ASCII | - |
| alphanumeric | `#[garde(alphanumeric)]` | only letters and digits | - |
| email | `#[garde(email)]` | an email according to the HTML5 spec[^1] | `email` |
| url | `#[garde(url)]` | a URL | `url` |
| ip | `#[garde(ip)]` | an IP address (either IPv4 or IPv6) | - |
| ipv4 | `#[garde(ipv4)]` | an IPv4 address | - |
| ipv6 | `#[garde(ipv6)]` | an IPv6 address | - |
| credit card | `#[garde(credit_card)]` | a credit card number | `credit-card` |
| phone number | `#[garde(phone_number)]` | a phone number | `phone-number` |
| length | `#[garde(length(min=<usize>, max=<usize>)]` | a container with length in `min..=max` | - |
| char_count | `#[garde(char_count(min=<usize>, max=<usize>)]` | a string with character count in `min..=max` | - |
| grapheme_count | `#[garde(grapheme_count(min=<usize>, max=<usize>)]` | a string with grapheme count in `min..=max` | `unicode` |
| range | `#[garde(range(min=<expr>, max=<expr>))]` | a number in the range `min..=max` | - |
| contains | `#[garde(contains(<string>))]` | a string-like value containing a substring | - |
| prefix | `#[garde(prefix(<string>))]` | a string-like value prefixed by some string | - |
| suffix | `#[garde(suffix(<string>))]` | a string-like value suffixed by some string | - |
| pattern | `#[garde(pattern("<regex>"))]` | a string-like value matching some regular expression | `regex` |
| pattern | `#[garde(pattern(<matcher>))]` | a string-like value matched by some [Matcher](https://docs.rs/garde/latest/garde/rules/pattern/trait.Matcher.html) | - |
| dive | `#[garde(dive)]` | nested validation, calls `validate` on the value | - |
| skip | `#[garde(skip)]` | skip validation | - |
| custom | `#[garde(custom(<function or closure>))]` | a custom validator | - |

Additional notes:
- `required` is only available for `Option` fields.
- For `length` and `range`, either `min` or `max` may be omitted, but not both.
- `length` and `range` use an *inclusive* upper bound (`min..=max`).
- `length` uses `.chars().count()` for UTF-8 strings instead of `.len()`.
- `length` uses `.len()` for UTF-8 strings and calculate a size in *bytes*.
- Most likely, you want to use `grapheme_count` instead of `char_count`. For more information go to its [documentation](https://docs.rs/garde/latest/garde/rules/grapheme_count/index.html).
- For `contains`, `prefix`, and `suffix`, the pattern must be a string literal, because the `Pattern` API [is currently unstable](https://github.com/rust-lang/rust/issues/27721).
- Garde does not enable the default features of the `regex` crate - if you need extra regex features (e.g. Unicode) or better performance, add a dependency on `regex = "1"` to your `Cargo.toml`.

Expand Down Expand Up @@ -157,7 +159,10 @@ with `#[garde(transparent)]`:
```rust
#[derive(garde::Validate)]
#[garde(transparent)]
struct Username(#[garde(length(min = 3, max = 20))] String);
struct Username(
#[garde(length(max = 50), grapheme_count(min = 3, max = 20))]
String,
);

#[derive(garde::Validate)]
struct User {
Expand All @@ -171,20 +176,20 @@ The `username` field in the above example will inherit all the validation rules

```rust,ignore
User {
username: Username("")
username: Username("".into())
}.validate(&())

"username: length is lower than 3"
"username: grapheme count is lower than 3"
```

Without the `#[garde(transparent)]` attribute, it would instead be:

```rust,ignore
User {
username: Username("")
username: Username("".into())
}.validate(&())

"username[0]: length is lower than 3"
"username[0]: grapheme count is lower than 3"
```

Structs with the `#[garde(transparent)]` attribute may have more than one field, but there must be only one unskipped field. That means every field other than the one you wish to validate must be `#[garde(skip)]`.
Expand Down Expand Up @@ -337,14 +342,14 @@ struct Bar {

| name | description | extra dependencies |
|--------------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------|
| `derive` | Enables the usage of the `derive(Validate)` macro | [`garde_derive`](https://crates.io/crates/garde_derive) |
| `derive` | Enables the usage of the `derive(Validate)` macro. | [`garde_derive`](https://crates.io/crates/garde_derive) |
| `url` | Validation of URLs via the `url` crate. | [`url`](https://crates.io/crates/url) |
| `email` | Validation of emails according to [HTML5](https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address) | [`regex`](https://crates.io/crates/regex), [`once_cell`](https://crates.io/crates/once_cell) |
| `email-idna` | Support for [Internationalizing Domain Names for Applications](https://url.spec.whatwg.org/#idna) in email addresses | [`idna`](https://crates.io/crates/idna) |
| `regex` | Support for regular expressions in `pattern` via the `regex` crate | [`regex`](https://crates.io/crates/regex), [`once_cell`](https://crates.io/crates/once_cell) |
| `credit-card` | Validation of credit card numbers via the `card-validate` crate | [`card-validate`](https://crates.io/crates/card-validate) |
| `phone-number` | Validation of phone numbers via the `phonenumber` crate | [`phonenumber`](https://crates.io/crates/phonenumber) |

| `email` | Validation of emails according to [HTML5](https://html.spec.whatwg.org/multipage/forms.html#valid-e-mail-address). | [`regex`](https://crates.io/crates/regex), [`once_cell`](https://crates.io/crates/once_cell) |
| `email-idna` | Support for [Internationalizing Domain Names for Applications](https://url.spec.whatwg.org/#idna) in email addresses. | [`idna`](https://crates.io/crates/idna) |
| `regex` | Support for regular expressions in `pattern` via the `regex` crate. | [`regex`](https://crates.io/crates/regex), [`once_cell`](https://crates.io/crates/once_cell) |
| `credit-card` | Validation of credit card numbers via the `card-validate` crate. | [`card-validate`](https://crates.io/crates/card-validate) |
| `phone-number` | Validation of phone numbers via the `phonenumber` crate. | [`phonenumber`](https://crates.io/crates/phonenumber) |
| `unicode` | Validation of grapheme count in strings via the `unicode-segmentation` crate. | [`unicode-segmentation`](https://crates.io/crates/unicode-segmentation) |

### Why `garde`?

Expand Down
17 changes: 10 additions & 7 deletions garde/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ default = [
"email",
"email-idna",
"regex",
"unicode",
]
serde = ["dep:serde", "compact_str/serde"]
derive = ["dep:garde_derive"]
Expand All @@ -31,12 +32,13 @@ email-idna = ["dep:idna"]
regex = ["dep:regex", "dep:once_cell", "garde_derive?/regex"]
pattern = ["regex"] # for backward compatibility with <0.14.0
js-sys = ["dep:js-sys"]
unicode = ["dep:unicode-segmentation"]

[dependencies]
garde_derive = { version = "0.17.0", path = "../garde_derive", optional = true, default-features = false }

smallvec = { version = "1.11.0", default-features = false }
compact_str = { version = "0.7.1", default-features = false }
smallvec = { version = "1.11", default-features = false }
compact_str = { version = "0.7", default-features = false }

serde = { version = "1", features = ["derive"], optional = true }
url = { version = "2", optional = true }
Expand All @@ -46,22 +48,23 @@ regex = { version = "1", default-features = false, features = [
"std",
], optional = true }
once_cell = { version = "1", optional = true }
idna = { version = "0.3", optional = true }
idna = { version = "0.5", optional = true }
unicode-segmentation = { version = "1.10", optional = true }

[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies]
js-sys = { version = "0.3", optional = true }

[dev-dependencies]
trybuild = { version = "1.0" }
insta = { version = "1.29" }
owo-colors = { version = "3.5.0" }
glob = "0.3.1"
owo-colors = { version = "4.0" }
glob = "0.3"

[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dev-dependencies]
wasm-bindgen-test = "0.3.38"
wasm-bindgen-test = "0.3"

[target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dev-dependencies]
criterion = "0.4"
criterion = "0.5"

[[bench]]
name = "validation"
Expand Down
2 changes: 1 addition & 1 deletion garde/src/rules/byte_length.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
//! [`ByteLength`] is implemented for any `T: HasByteLength`.
//!
//! In case of string types, [`HasByteLength::byte_length`] should return the number of _bytes_ as opposed to the number of _characters_.
//! For validation of length counted in _characters_, see the [`crate::rules::length`] rule.
//!
//! Here's what implementing the trait for a custom string-like type might look like:
//! ```rust
Expand All @@ -31,6 +30,7 @@
use super::AsStr;
use crate::error::Error;

#[deprecated = "the `byte_length` attribute is deprecated. Use `length` instead. (See https://github.com/jprochazk/garde/issues/84)"]
pub fn apply<T: ByteLength>(v: &T, (min, max): (usize, usize)) -> Result<(), Error> {
if let Err(e) = v.validate_byte_length(min, max) {
match e {
Expand Down
122 changes: 122 additions & 0 deletions garde/src/rules/char_count.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
//! Character count validation. Works as `string.chars().count()` and counts **USVs** (Unicode Scalar Value).
//!
//! It's important to remember that `char` represents a **USV**, and [may not match your idea](https://stackoverflow.com/a/46290728) of
//! what a 'character' is. Using grapheme clusters (the [`crate::rules::grapheme_count`] rule) may be what you actually want.
//!
//! ```rust
//! #[derive(garde::Validate)]
//! struct Test {
//! #[garde(char_count(min=1, max=100))]
//! v: String,
//! }
//! ```
//!
//! The entrypoint is the [`CharCount`] trait. Implementing this trait for a type allows that type to be used with the `#[garde(char_count(...))]` rule.
//!
//! For validation of length counted in _bytes_, see the [`crate::rules::length`] rule.
//!
//! Here's what implementing the trait for a custom string-like type might look like:
//! ```rust
//! #[repr(transparent)]
//! struct MyString(String);
//!
//! impl garde::rules::char_count::HasCharCount for MyString {
//! fn char_count(&self) -> usize {
//! self.0.chars().count()
//! }
//! }
//! ```

use crate::error::Error;

pub fn apply<T: CharCount>(v: &T, (min, max): (usize, usize)) -> Result<(), Error> {
if let Err(e) = v.validate_char_count(min, max) {
match e {
InvalidLength::Min => {
return Err(Error::new(format!("character count is lower than {min}")))
}
InvalidLength::Max => {
return Err(Error::new(format!("character count is greater than {max}")))
}
}
}
Ok(())
}

pub trait CharCount {
fn validate_char_count(&self, min: usize, max: usize) -> Result<(), InvalidLength>;
}

pub enum InvalidLength {
Min,
Max,
}

#[allow(clippy::len_without_is_empty)]
pub trait HasCharCount {
fn char_count(&self) -> usize;
}

impl<T: HasCharCount> CharCount for T {
fn validate_char_count(&self, min: usize, max: usize) -> Result<(), InvalidLength> {
let len = HasCharCount::char_count(self);
if len < min {
Err(InvalidLength::Min)
} else if len > max {
Err(InvalidLength::Max)
} else {
Ok(())
}
}
}

impl<T: CharCount> CharCount for Option<T> {
fn validate_char_count(&self, min: usize, max: usize) -> Result<(), InvalidLength> {
match self {
Some(value) => value.validate_char_count(min, max),
None => Ok(()),
}
}
}

impl HasCharCount for String {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl<'a> HasCharCount for &'a String {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl<'a> HasCharCount for &'a str {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl<'a> HasCharCount for std::borrow::Cow<'a, str> {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl<'a, 'b> HasCharCount for &'a std::borrow::Cow<'b, str> {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl HasCharCount for Box<str> {
fn char_count(&self) -> usize {
self.chars().count()
}
}

impl<'a> HasCharCount for &'a Box<str> {
fn char_count(&self) -> usize {
self.chars().count()
}
}
Loading
Loading