Introduce Trimmed validator

This commit introduces the `Trimmed` validator that ensures a string
cannot start or end with a list of specific values.

The default values used are a selected list of Unicode invisible
characters.

To support this change, the StartsWith and EndsWith validators were
modified so they can also support multiple values to check for.

While StartsWith and EndsWith are more generic, and also perform
start-of-array and end-of-array kinds of checks, Trimmed is more
focused on string inputs, which tailors to a more specific use
case.
This commit is contained in:
Alexandre Gomes Gaigalas 2026-02-20 19:26:55 -03:00
commit 618b2a3661
No known key found for this signature in database
GPG key ID: C68060CCE0AE33F0
29 changed files with 480 additions and 55 deletions

View file

@ -49,7 +49,7 @@ In this page you will find a list of validators by their category.
**Objects**: [Attributes][] - [Instance][] - [ObjectType][] - [Property][] - [PropertyExists][] - [PropertyOptional][]
**Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Format][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][]
**Strings**: [Alnum][] - [Alpha][] - [Base64][] - [Charset][] - [Consonant][] - [Contains][] - [ContainsAny][] - [ContainsCount][] - [Control][] - [Digit][] - [Emoji][] - [EndsWith][] - [Format][] - [Graph][] - [HexRgbColor][] - [In][] - [Json][] - [Lowercase][] - [Phone][] - [PostalCode][] - [Printable][] - [Punct][] - [Regex][] - [Slug][] - [Sorted][] - [Space][] - [Spaced][] - [StartsWith][] - [StringType][] - [StringVal][] - [Trimmed][] - [Uppercase][] - [Uuid][] - [Version][] - [Vowel][] - [Xdigit][]
**Structures**: [Attributes][] - [Key][] - [KeyExists][] - [KeyOptional][] - [KeySet][] - [Property][] - [PropertyExists][] - [PropertyOptional][]
@ -203,6 +203,7 @@ In this page you will find a list of validators by their category.
- [Templated][] - `v::templated('You must provide a valid email', v::email())->assert('foo@bar.com');`
- [Time][] - `v::time()->assert('00:00:00');`
- [Tld][] - `v::tld()->assert('com');`
- [Trimmed][] - `v::trimmed()->assert('lorem ipsum');`
- [TrueVal][] - `v::trueVal()->assert(true);`
- [Undef][] - `v::undef()->assert('');`
- [UndefOr][] - `v::undefOr(v::alpha())->assert('');`
@ -351,7 +352,7 @@ In this page you will find a list of validators by their category.
[Sorted]: validators/Sorted.md "Validates whether the input is sorted in a certain order or not."
[Space]: validators/Space.md "Validates whether the input contains only whitespaces characters."
[Spaced]: validators/Spaced.md "Validates if a string contains at least one whitespace (spaces, tabs, or line breaks);"
[StartsWith]: validators/StartsWith.md "Validates whether the input starts with a given value."
[StartsWith]: validators/StartsWith.md "Validates whether the input starts with one of the given values."
[StringType]: validators/StringType.md "Validates whether the type of an input is string or not."
[StringVal]: validators/StringVal.md "Validates whether the input can be used as a string."
[SubdivisionCode]: validators/SubdivisionCode.md "Validates subdivision country codes according to ISO 3166-2."
@ -360,6 +361,7 @@ In this page you will find a list of validators by their category.
[Templated]: validators/Templated.md "Defines a validator with a custom message template."
[Time]: validators/Time.md "Validates whether an input is a time or not. The `$format` argument should be in"
[Tld]: validators/Tld.md "Validates whether the input is a top-level domain."
[Trimmed]: validators/Trimmed.md "Validates whether the input string does not start or end with the given values."
[TrueVal]: validators/TrueVal.md "Validates if a value is considered as `true`."
[Undef]: validators/Undef.md "Validates if the given input is undefined. By _undefined_ we consider `null` or an empty string (`''`)."
[UndefOr]: validators/UndefOr.md "Validates the input using a defined validator when the input is not `null` or an empty string (`''`)."

View file

@ -8,15 +8,22 @@ SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
# EndsWith
- `EndsWith(mixed $endValue)`
- `EndsWith(mixed $endValue, mixed ...$endValues)`
This validator is similar to `Contains()`, but validates
only if the value is at the end of the input.
only if one of the values is at the end of the input. Only
string inputs and string end values are checked; nonstring
values are considered invalid but will not produce PHP errors
thanks to internal type guards.
For strings:
For strings (non-string inputs are always rejected):
```php
v::endsWith('ipsum')->assert('lorem ipsum');
// Validation passes successfully
v::endsWith(', PhD', ', doctor')->assert('Jane Doe, PhD');
// Validation passes successfully
```
For arrays:
@ -24,9 +31,15 @@ For arrays:
```php
v::endsWith('ipsum')->assert(['lorem', 'ipsum']);
// Validation passes successfully
v::endsWith('.', ';')->assert(['this', 'is', 'a', 'tokenized', 'phrase', '.']);
// Validation passes successfully
v::endsWith('.', ';')->assert(['this', 'is', 'a', 'tokenized', 'phrase']);
// → `["this", "is", "a", "tokenized", "phrase"]` must end with "." or ";"
```
Message template for this validator includes `{{endValue}}`.
Message template for this validator includes `{{endValue}}` and `{{endValues}}`.
## Templates
@ -37,12 +50,20 @@ Message template for this validator includes `{{endValue}}`.
| `default` | {{subject}} must end with {{endValue}} |
| `inverted` | {{subject}} must not end with {{endValue}} |
### `EndsWith::TEMPLATE_MULTIPLE_VALUES`
| Mode | Template |
| ---------: | :------------------------------------------------------- |
| `default` | {{subject}} must end with {{endValues&#124;list:or}} |
| `inverted` | {{subject}} must not end with {{endValues&#124;list:or}} |
## Template placeholders
| Placeholder | Description |
| ----------- | ---------------------------------------------------------------- |
| `endValue` | |
| `subject` | The validated input or the custom validator name (if specified). |
| `endValue` | The value that will be checked to be at the end of the input. |
| `endValues` | Additional values to check. |
## Categorization
@ -53,6 +74,7 @@ Message template for this validator includes `{{endValue}}`.
| Version | Description |
| ------: | :---------------------------------- |
| 3.1.0 | Added support for multiple values |
| 3.0.0 | Case-insensitive comparison removed |
| 0.3.9 | Created |
@ -62,3 +84,4 @@ Message template for this validator includes `{{endValue}}`.
- [In](In.md)
- [Regex](Regex.md)
- [StartsWith](StartsWith.md)
- [Trimmed](Trimmed.md)

View file

@ -9,8 +9,9 @@ SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
# StartsWith
- `StartsWith(mixed $startValue)`
- `StartsWith(mixed $startValue, mixed ...$startValues)`
Validates whether the input starts with a given value.
Validates whether the input starts with one of the given values.
This validator is similar to [Contains](Contains.md), but validates only
if the value is at the beginning of the input.
@ -20,6 +21,9 @@ For strings:
```php
v::startsWith('lorem')->assert('lorem ipsum');
// Validation passes successfully
v::startsWith('Dr.', 'Mr.')->assert('Dr. Jane Doe');
// Validation passes successfully
```
For arrays:
@ -27,9 +31,15 @@ For arrays:
```php
v::startsWith('lorem')->assert(['lorem', 'ipsum']);
// Validation passes successfully
v::startsWith(0, 1)->assert([0, 1, 2, 3]);
// Validation passes successfully
v::startsWith(0, 1)->assert([1, 2, 3]);
// Validation passes successfully
```
Message template for this validator includes `{{startValue}}`.
Message template for this validator includes `{{startValue}}` and `{{startValues}}`.
## Templates
@ -40,12 +50,20 @@ Message template for this validator includes `{{startValue}}`.
| `default` | {{subject}} must start with {{startValue}} |
| `inverted` | {{subject}} must not start with {{startValue}} |
### `StartsWith::TEMPLATE_MULTIPLE_VALUES`
| Mode | Template |
| ---------: | :----------------------------------------------------------- |
| `default` | {{subject}} must start with {{startValues&#124;list:or}} |
| `inverted` | {{subject}} must not start with {{startValues&#124;list:or}} |
## Template placeholders
| Placeholder | Description |
| ------------ | ---------------------------------------------------------------- |
| `subject` | The validated input or the custom validator name (if specified). |
| `startValue` | |
| Placeholder | Description |
| ------------- | ---------------------------------------------------------------- |
| `subject` | The validated input or the custom validator name (if specified). |
| `startValue` | The value that will be checked to be at the start of the input. |
| `startValues` | Additional values to check. |
## Categorization
@ -56,6 +74,7 @@ Message template for this validator includes `{{startValue}}`.
| Version | Description |
| ------: | :---------------------------------- |
| 3.1.0 | Added support for multiple values |
| 3.0.0 | Case-insensitive comparison removed |
| 0.3.9 | Created |
@ -65,3 +84,4 @@ Message template for this validator includes `{{startValue}}`.
- [EndsWith](EndsWith.md)
- [In](In.md)
- [Regex](Regex.md)
- [Trimmed](Trimmed.md)

View file

@ -0,0 +1,78 @@
<!--
SPDX-License-Identifier: MIT
SPDX-FileCopyrightText: (c) Respect Project Contributors
-->
# Trimmed
- `Trimmed()`
- `Trimmed(string ...$trimValues)`
Validates whether the input string does not start or end with the given values.
When no values are provided, this validator uses a default list of Unicode invisible characters (including regular whitespace, non-breaking spaces, and zero-width characters).
With the default values:
```php
v::trimmed()->assert('lorem ipsum');
// Validation passes successfully
v::trimmed()->assert("\u{200B}lorem");
// → "lorem" must not contain leading or trailing whitespace
```
With custom values:
```php
v::trimmed('Dr.', 'Mr.', 'PhD.')->assert('John');
// Validation passes successfully
v::trimmed('Dr.', 'Mr.', 'PhD.')->assert('Dr. John');
// → "Dr. John" must not contain leading or trailing "Dr.", "Mr.", or "PhD."
v::trimmed('Dr.', 'Mr.', ', PhD')->assert('John Doe, PhD');
// → "John Doe, PhD" must not contain leading or trailing "Dr.", "Mr.", or ", PhD"
```
This validator composes [StartsWith](StartsWith.md) and [EndsWith](EndsWith.md).
## Templates
### `Trimmed::TEMPLATE_STANDARD`
| Mode | Template |
| ---------: | :---------------------------------------------------------- |
| `default` | {{subject}} must not contain leading or trailing whitespace |
| `inverted` | {{subject}} must contain leading or trailing whitespace |
### `Trimmed::TEMPLATE_CUSTOM`
| Mode | Template |
| ---------: | :--------------------------------------------------------------------------- |
| `default` | {{subject}} must not contain leading or trailing {{trimValues&#124;list:or}} |
| `inverted` | {{subject}} must contain leading or trailing {{trimValues&#124;list:or}} |
## Template placeholders
| Placeholder | Description |
| ------------ | ---------------------------------------------------------------- |
| `subject` | The validated input or the custom validator name (if specified). |
| `trimValues` | The values that will be checked at start end end of input. |
## Categorization
- Strings
## Changelog
| Version | Description |
| ------: | :---------- |
| 3.1.0 | Created |
## See Also
- [EndsWith](EndsWith.md)
- [Space](Space.md)
- [Spaced](Spaced.md)
- [StartsWith](StartsWith.md)

View file

@ -103,7 +103,7 @@ interface AllBuilder
public static function allEmoji(): Chain;
public static function allEndsWith(mixed $endValue): Chain;
public static function allEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public static function allEquals(mixed $compareTo): Chain;
@ -269,7 +269,7 @@ interface AllBuilder
public static function allSpaced(): Chain;
public static function allStartsWith(mixed $startValue): Chain;
public static function allStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public static function allStringType(): Chain;
@ -286,6 +286,8 @@ interface AllBuilder
public static function allTld(): Chain;
public static function allTrimmed(string ...$trimValues): Chain;
public static function allTrueVal(): Chain;
public static function allUndef(): Chain;

View file

@ -103,7 +103,7 @@ interface AllChain
public function allEmoji(): Chain;
public function allEndsWith(mixed $endValue): Chain;
public function allEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public function allEquals(mixed $compareTo): Chain;
@ -269,7 +269,7 @@ interface AllChain
public function allSpaced(): Chain;
public function allStartsWith(mixed $startValue): Chain;
public function allStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public function allStringType(): Chain;
@ -286,6 +286,8 @@ interface AllChain
public function allTld(): Chain;
public function allTrimmed(string ...$trimValues): Chain;
public function allTrueVal(): Chain;
public function allUndef(): Chain;

View file

@ -108,7 +108,7 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min
public static function emoji(): Chain;
public static function endsWith(mixed $endValue): Chain;
public static function endsWith(mixed $endValue, mixed ...$endValues): Chain;
public static function equals(mixed $compareTo): Chain;
@ -296,7 +296,7 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min
public static function spaced(): Chain;
public static function startsWith(mixed $startValue): Chain;
public static function startsWith(mixed $startValue, mixed ...$startValues): Chain;
public static function stringType(): Chain;
@ -316,6 +316,8 @@ interface Builder extends AllBuilder, KeyBuilder, LengthBuilder, MaxBuilder, Min
public static function tld(): Chain;
public static function trimmed(string ...$trimValues): Chain;
public static function trueVal(): Chain;
public static function undef(): Chain;

6
src/Mixins/Chain.php generated
View file

@ -110,7 +110,7 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi
public function emoji(): Chain;
public function endsWith(mixed $endValue): Chain;
public function endsWith(mixed $endValue, mixed ...$endValues): Chain;
public function equals(mixed $compareTo): Chain;
@ -298,7 +298,7 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi
public function spaced(): Chain;
public function startsWith(mixed $startValue): Chain;
public function startsWith(mixed $startValue, mixed ...$startValues): Chain;
public function stringType(): Chain;
@ -318,6 +318,8 @@ interface Chain extends Validator, AllChain, KeyChain, LengthChain, MaxChain, Mi
public function tld(): Chain;
public function trimmed(string ...$trimValues): Chain;
public function trueVal(): Chain;
public function undef(): Chain;

View file

@ -105,7 +105,7 @@ interface KeyBuilder
public static function keyEmoji(int|string $key): Chain;
public static function keyEndsWith(int|string $key, mixed $endValue): Chain;
public static function keyEndsWith(int|string $key, mixed $endValue, mixed ...$endValues): Chain;
public static function keyEquals(int|string $key, mixed $compareTo): Chain;
@ -271,7 +271,7 @@ interface KeyBuilder
public static function keySpaced(int|string $key): Chain;
public static function keyStartsWith(int|string $key, mixed $startValue): Chain;
public static function keyStartsWith(int|string $key, mixed $startValue, mixed ...$startValues): Chain;
public static function keyStringType(int|string $key): Chain;
@ -288,6 +288,8 @@ interface KeyBuilder
public static function keyTld(int|string $key): Chain;
public static function keyTrimmed(int|string $key, string ...$trimValues): Chain;
public static function keyTrueVal(int|string $key): Chain;
public static function keyUndef(int|string $key): Chain;

View file

@ -105,7 +105,7 @@ interface KeyChain
public function keyEmoji(int|string $key): Chain;
public function keyEndsWith(int|string $key, mixed $endValue): Chain;
public function keyEndsWith(int|string $key, mixed $endValue, mixed ...$endValues): Chain;
public function keyEquals(int|string $key, mixed $compareTo): Chain;
@ -271,7 +271,7 @@ interface KeyChain
public function keySpaced(int|string $key): Chain;
public function keyStartsWith(int|string $key, mixed $startValue): Chain;
public function keyStartsWith(int|string $key, mixed $startValue, mixed ...$startValues): Chain;
public function keyStringType(int|string $key): Chain;
@ -288,6 +288,8 @@ interface KeyChain
public function keyTld(int|string $key): Chain;
public function keyTrimmed(int|string $key, string ...$trimValues): Chain;
public function keyTrueVal(int|string $key): Chain;
public function keyUndef(int|string $key): Chain;

View file

@ -105,7 +105,7 @@ interface NotBuilder
public static function notEmoji(): Chain;
public static function notEndsWith(mixed $endValue): Chain;
public static function notEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public static function notEquals(mixed $compareTo): Chain;
@ -287,7 +287,7 @@ interface NotBuilder
public static function notSpaced(): Chain;
public static function notStartsWith(mixed $startValue): Chain;
public static function notStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public static function notStringType(): Chain;
@ -304,6 +304,8 @@ interface NotBuilder
public static function notTld(): Chain;
public static function notTrimmed(string ...$trimValues): Chain;
public static function notTrueVal(): Chain;
public static function notUndef(): Chain;

View file

@ -105,7 +105,7 @@ interface NotChain
public function notEmoji(): Chain;
public function notEndsWith(mixed $endValue): Chain;
public function notEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public function notEquals(mixed $compareTo): Chain;
@ -287,7 +287,7 @@ interface NotChain
public function notSpaced(): Chain;
public function notStartsWith(mixed $startValue): Chain;
public function notStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public function notStringType(): Chain;
@ -304,6 +304,8 @@ interface NotChain
public function notTld(): Chain;
public function notTrimmed(string ...$trimValues): Chain;
public function notTrueVal(): Chain;
public function notUndef(): Chain;

View file

@ -105,7 +105,7 @@ interface NullOrBuilder
public static function nullOrEmoji(): Chain;
public static function nullOrEndsWith(mixed $endValue): Chain;
public static function nullOrEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public static function nullOrEquals(mixed $compareTo): Chain;
@ -289,7 +289,7 @@ interface NullOrBuilder
public static function nullOrSpaced(): Chain;
public static function nullOrStartsWith(mixed $startValue): Chain;
public static function nullOrStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public static function nullOrStringType(): Chain;
@ -306,6 +306,8 @@ interface NullOrBuilder
public static function nullOrTld(): Chain;
public static function nullOrTrimmed(string ...$trimValues): Chain;
public static function nullOrTrueVal(): Chain;
public static function nullOrUnique(): Chain;

View file

@ -105,7 +105,7 @@ interface NullOrChain
public function nullOrEmoji(): Chain;
public function nullOrEndsWith(mixed $endValue): Chain;
public function nullOrEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public function nullOrEquals(mixed $compareTo): Chain;
@ -289,7 +289,7 @@ interface NullOrChain
public function nullOrSpaced(): Chain;
public function nullOrStartsWith(mixed $startValue): Chain;
public function nullOrStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public function nullOrStringType(): Chain;
@ -306,6 +306,8 @@ interface NullOrChain
public function nullOrTld(): Chain;
public function nullOrTrimmed(string ...$trimValues): Chain;
public function nullOrTrueVal(): Chain;
public function nullOrUnique(): Chain;

View file

@ -105,7 +105,7 @@ interface PropertyBuilder
public static function propertyEmoji(string $propertyName): Chain;
public static function propertyEndsWith(string $propertyName, mixed $endValue): Chain;
public static function propertyEndsWith(string $propertyName, mixed $endValue, mixed ...$endValues): Chain;
public static function propertyEquals(string $propertyName, mixed $compareTo): Chain;
@ -271,7 +271,7 @@ interface PropertyBuilder
public static function propertySpaced(string $propertyName): Chain;
public static function propertyStartsWith(string $propertyName, mixed $startValue): Chain;
public static function propertyStartsWith(string $propertyName, mixed $startValue, mixed ...$startValues): Chain;
public static function propertyStringType(string $propertyName): Chain;
@ -288,6 +288,8 @@ interface PropertyBuilder
public static function propertyTld(string $propertyName): Chain;
public static function propertyTrimmed(string $propertyName, string ...$trimValues): Chain;
public static function propertyTrueVal(string $propertyName): Chain;
public static function propertyUndef(string $propertyName): Chain;

View file

@ -105,7 +105,7 @@ interface PropertyChain
public function propertyEmoji(string $propertyName): Chain;
public function propertyEndsWith(string $propertyName, mixed $endValue): Chain;
public function propertyEndsWith(string $propertyName, mixed $endValue, mixed ...$endValues): Chain;
public function propertyEquals(string $propertyName, mixed $compareTo): Chain;
@ -271,7 +271,7 @@ interface PropertyChain
public function propertySpaced(string $propertyName): Chain;
public function propertyStartsWith(string $propertyName, mixed $startValue): Chain;
public function propertyStartsWith(string $propertyName, mixed $startValue, mixed ...$startValues): Chain;
public function propertyStringType(string $propertyName): Chain;
@ -288,6 +288,8 @@ interface PropertyChain
public function propertyTld(string $propertyName): Chain;
public function propertyTrimmed(string $propertyName, string ...$trimValues): Chain;
public function propertyTrueVal(string $propertyName): Chain;
public function propertyUndef(string $propertyName): Chain;

View file

@ -103,7 +103,7 @@ interface UndefOrBuilder
public static function undefOrEmoji(): Chain;
public static function undefOrEndsWith(mixed $endValue): Chain;
public static function undefOrEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public static function undefOrEquals(mixed $compareTo): Chain;
@ -287,7 +287,7 @@ interface UndefOrBuilder
public static function undefOrSpaced(): Chain;
public static function undefOrStartsWith(mixed $startValue): Chain;
public static function undefOrStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public static function undefOrStringType(): Chain;
@ -304,6 +304,8 @@ interface UndefOrBuilder
public static function undefOrTld(): Chain;
public static function undefOrTrimmed(string ...$trimValues): Chain;
public static function undefOrTrueVal(): Chain;
public static function undefOrUnique(): Chain;

View file

@ -103,7 +103,7 @@ interface UndefOrChain
public function undefOrEmoji(): Chain;
public function undefOrEndsWith(mixed $endValue): Chain;
public function undefOrEndsWith(mixed $endValue, mixed ...$endValues): Chain;
public function undefOrEquals(mixed $compareTo): Chain;
@ -287,7 +287,7 @@ interface UndefOrChain
public function undefOrSpaced(): Chain;
public function undefOrStartsWith(mixed $startValue): Chain;
public function undefOrStartsWith(mixed $startValue, mixed ...$startValues): Chain;
public function undefOrStringType(): Chain;
@ -304,6 +304,8 @@ interface UndefOrChain
public function undefOrTld(): Chain;
public function undefOrTrimmed(string ...$trimValues): Chain;
public function undefOrTrueVal(): Chain;
public function undefOrUnique(): Chain;

View file

@ -21,11 +21,18 @@ abstract class Envelope implements Validator
public function __construct(
private readonly Validator $validator,
private readonly array $parameters = [],
private readonly string $template = Validator::TEMPLATE_STANDARD,
) {
}
public function evaluate(mixed $input): Result
{
return Result::of($this->validator->evaluate($input)->hasPassed, $input, $this, $this->parameters);
return Result::of(
$this->validator->evaluate($input)->hasPassed,
$input,
$this,
$this->parameters,
$this->template,
);
}
}

View file

@ -20,8 +20,10 @@ use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validator;
use function count;
use function end;
use function is_array;
use function is_string;
use function mb_strlen;
use function mb_strrpos;
@ -30,26 +32,56 @@ use function mb_strrpos;
'{{subject}} must end with {{endValue}}',
'{{subject}} must not end with {{endValue}}',
)]
#[Template(
'{{subject}} must end with {{endValues|list:or}}',
'{{subject}} must not end with {{endValues|list:or}}',
self::TEMPLATE_MULTIPLE_VALUES,
)]
final readonly class EndsWith implements Validator
{
public const string TEMPLATE_MULTIPLE_VALUES = '__multiple_values__';
/** @var non-empty-array<mixed> */
private array $endValues;
public function __construct(
private mixed $endValue,
mixed $endValue,
mixed ...$endValues,
) {
$this->endValues = [$endValue, ...$endValues];
}
public function evaluate(mixed $input): Result
{
$parameters = ['endValue' => $this->endValue];
$template = self::TEMPLATE_STANDARD;
$parameters = [
'endValue' => $this->endValues[0],
'endValues' => $this->endValues,
];
return Result::of($this->validateIdentical($input), $input, $this, $parameters);
if (count($this->endValues) > 1) {
$template = self::TEMPLATE_MULTIPLE_VALUES;
}
return Result::of($this->validateIdentical($input), $input, $this, $parameters, $template);
}
private function validateIdentical(mixed $input): bool
{
if (is_array($input)) {
return end($input) === $this->endValue;
foreach ($this->endValues as $endValue) {
if (is_array($input) && end($input) === $endValue) {
return true;
}
// ensure both operands are strings before using mb_ functions
if (
is_string($input) && is_string($endValue)
&& mb_strrpos($input, $endValue) === mb_strlen($input) - mb_strlen($endValue)
) {
return true;
}
}
return mb_strrpos($input, $this->endValue) === mb_strlen($input) - mb_strlen($this->endValue);
return false;
}
}

View file

@ -20,6 +20,7 @@ use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validator;
use function count;
use function is_array;
use function is_string;
use function mb_strpos;
@ -30,28 +31,50 @@ use function reset;
'{{subject}} must start with {{startValue}}',
'{{subject}} must not start with {{startValue}}',
)]
#[Template(
'{{subject}} must start with {{startValues|list:or}}',
'{{subject}} must not start with {{startValues|list:or}}',
self::TEMPLATE_MULTIPLE_VALUES,
)]
final readonly class StartsWith implements Validator
{
public const string TEMPLATE_MULTIPLE_VALUES = '__multiple_values__';
/** @var non-empty-array<mixed> */
private array $startValues;
public function __construct(
private mixed $startValue,
mixed $startValue,
mixed ...$startValues,
) {
$this->startValues = [$startValue, ...$startValues];
}
public function evaluate(mixed $input): Result
{
$parameters = ['startValue' => $this->startValue];
$template = self::TEMPLATE_STANDARD;
$parameters = [
'startValue' => $this->startValues[0],
'startValues' => $this->startValues,
];
return Result::of($this->validateIdentical($input), $input, $this, $parameters);
if (count($this->startValues) > 1) {
$template = self::TEMPLATE_MULTIPLE_VALUES;
}
return Result::of($this->validateIdentical($input), $input, $this, $parameters, $template);
}
protected function validateIdentical(mixed $input): bool
{
if (is_array($input)) {
return reset($input) === $this->startValue;
}
foreach ($this->startValues as $startValue) {
if (is_array($input) && reset($input) === $startValue) {
return true;
}
if (is_string($input) && is_string($this->startValue)) {
return mb_strpos($input, $this->startValue) === 0;
if (is_string($input) && is_string($startValue) && mb_strpos($input, $startValue) === 0) {
return true;
}
}
return false;

View file

@ -0,0 +1,87 @@
<?php
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
*/
declare(strict_types=1);
namespace Respect\Validation\Validators;
use Attribute;
use Respect\Validation\Message\Template;
use Respect\Validation\Validator;
use Respect\Validation\Validators\Core\Envelope;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
#[Template(
'{{subject}} must not contain leading or trailing whitespace',
'{{subject}} must contain leading or trailing whitespace',
Validator::TEMPLATE_STANDARD,
)]
#[Template(
'{{subject}} must not contain leading or trailing {{trimValues|list:or}}',
'{{subject}} must contain leading or trailing {{trimValues|list:or}}',
self::TEMPLATE_CUSTOM,
)]
final class Trimmed extends Envelope
{
public const string TEMPLATE_CUSTOM = '__custom__';
/** Unicode whitespace and zero-width characters. */
private const array DEFAULT_TRIM_VALUES = [
"\u{0009}", // CHARACTER TABULATION
"\u{000A}", // LINE FEED
"\u{000B}", // LINE TABULATION
"\u{000C}", // FORM FEED
"\u{000D}", // CARRIAGE RETURN
"\u{0020}", // SPACE
"\u{0085}", // NEXT LINE
"\u{00A0}", // NO-BREAK SPACE
"\u{1680}", // OGHAM SPACE MARK
"\u{180E}", // MONGOLIAN VOWEL SEPARATOR
"\u{2000}", // EN QUAD
"\u{2001}", // EM QUAD
"\u{2002}", // EN SPACE
"\u{2003}", // EM SPACE
"\u{2004}", // THREE-PER-EM SPACE
"\u{2005}", // FOUR-PER-EM SPACE
"\u{2006}", // SIX-PER-EM SPACE
"\u{2007}", // FIGURE SPACE
"\u{2008}", // PUNCTUATION SPACE
"\u{2009}", // THIN SPACE
"\u{200A}", // HAIR SPACE
"\u{200B}", // ZERO WIDTH SPACE
"\u{200C}", // ZERO WIDTH NON-JOINER
"\u{200D}", // ZERO WIDTH JOINER
"\u{2028}", // LINE SEPARATOR
"\u{2029}", // PARAGRAPH SEPARATOR
"\u{202F}", // NARROW NO-BREAK SPACE
"\u{205F}", // MEDIUM MATHEMATICAL SPACE
"\u{2060}", // WORD JOINER
"\u{3000}", // IDEOGRAPHIC SPACE
"\u{FEFF}", // ZERO WIDTH NO-BREAK SPACE
];
public function __construct(string ...$trimValues)
{
$hasCustomTrimValues = $trimValues !== [];
$trimValues = $hasCustomTrimValues ? $trimValues : self::DEFAULT_TRIM_VALUES;
parent::__construct(
new ShortCircuit(
new StringType(),
new Not(
new AnyOf(
new StartsWith(...$trimValues),
new EndsWith(...$trimValues),
),
),
),
$hasCustomTrimValues ? ['trimValues' => $trimValues] : [],
$hasCustomTrimValues ? self::TEMPLATE_CUSTOM : Validator::TEMPLATE_STANDARD,
);
}
}

View file

@ -9,6 +9,8 @@
declare(strict_types=1);
use Respect\Validation\Exceptions\ValidationException;
test('Scenario #1', catchMessage(
fn() => v::endsWith('foo')->assert('bar'),
fn(string $message) => expect($message)->toBe('"bar" must end with "foo"'),
@ -28,3 +30,21 @@ test('Scenario #4', catchFullMessage(
fn() => v::not(v::endsWith('foo'))->assert(['bar', 'foo']),
fn(string $fullMessage) => expect($fullMessage)->toBe('- `["bar", "foo"]` must not end with "foo"'),
));
test('Scenario #5', catchMessage(
fn() => v::endsWith('Mr.', 'Dr.')->assert('John Doe'),
fn(string $message) => expect($message)->toBe('"John Doe" must end with "Mr." or "Dr."'),
));
test('Scenario #6', catchFullMessage(
fn() => v::not(v::endsWith('divorced.', 'PhD.'))->assert('John Doe, PhD.'),
fn(string $fullMessage) => expect($fullMessage)->toBe('- "John Doe, PhD." must not end with "divorced." or "PhD."'),
));
// ensure non-string values do not throw errors and are considered invalid
test('non-string input or end value are invalid', function (): void {
expect(fn() => v::endsWith('foo')->assert(123))
->toThrow(ValidationException::class);
expect(fn() => v::endsWith(123)->assert('foo'))
->toThrow(ValidationException::class);
});

View file

@ -28,3 +28,13 @@ test('Scenario #4', catchFullMessage(
fn() => v::not(v::startsWith('c'))->assert(['c', 'd']),
fn(string $fullMessage) => expect($fullMessage)->toBe('- `["c", "d"]` must not start with "c"'),
));
test('Scenario #5', catchMessage(
fn() => v::startsWith('Mr.', 'Dr.')->assert('John Doe'),
fn(string $message) => expect($message)->toBe('"John Doe" must start with "Mr." or "Dr."'),
));
test('Scenario #6', catchFullMessage(
fn() => v::not(v::startsWith('Mr.', 'Dr.'))->assert('Dr. John Doe'),
fn(string $fullMessage) => expect($fullMessage)->toBe('- "Dr. John Doe" must not start with "Mr." or "Dr."'),
));

View file

@ -0,0 +1,35 @@
<?php
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
*/
declare(strict_types=1);
test('default template', catchAll(
fn() => v::trimmed()->assert(' word'),
fn(string $message, string $fullMessage, array $messages) => expect()
->and($message)->toBe('" word" must not contain leading or trailing whitespace')
->and($fullMessage)->toBe('- " word" must not contain leading or trailing whitespace')
->and($messages)->toBe(['trimmed' => '" word" must not contain leading or trailing whitespace']),
));
test('inverted template', catchAll(
fn() => v::not(v::trimmed())->assert('word'),
fn(string $message, string $fullMessage, array $messages) => expect()
->and($message)->toBe('"word" must contain leading or trailing whitespace')
->and($fullMessage)->toBe('- "word" must contain leading or trailing whitespace')
->and($messages)->toBe(['notTrimmed' => '"word" must contain leading or trailing whitespace']),
));
test('custom alternatives', catchMessage(
fn() => v::trimmed('foo', 'bar')->assert('foobaz'),
fn(string $message) => expect($message)->toBe('"foobaz" must not contain leading or trailing "foo" or "bar"'),
));
test('custom alternatives inverted template', catchMessage(
fn() => v::not(v::trimmed('foo', 'bar'))->assert('bazqux'),
fn(string $message) => expect($message)->toBe('"bazqux" must contain leading or trailing "foo" or "bar"'),
));

View file

@ -169,6 +169,7 @@ trait SmokeTestProvider
yield 'Templated' => [new vs\Templated('Foo', new vs\StringVal()), 'foo'];
yield 'Time' => [new vs\Time(), '12:34:56'];
yield 'Tld' => [new vs\Tld(), 'com'];
yield 'Trimmed' => [new vs\Trimmed(), 'example'];
yield 'TrueVal' => [new vs\TrueVal(), true];
yield 'Undef' => [new vs\Undef(), null];
yield 'UndefOr' => [new vs\UndefOr(new vs\IntVal()), null];

View file

@ -28,6 +28,8 @@ final class EndsWithTest extends RuleTestCase
[new EndsWith('foo'), ['bar', 'foo']],
[new EndsWith('foo'), 'barbazfoo'],
[new EndsWith('foo'), 'foobazfoo'],
[new EndsWith('foo', 'bar'), 'bazbar'],
[new EndsWith('foo', 'bar'), ['baz', 'bar']],
[new EndsWith(1), [2, 3, 1]],
[new EndsWith('1'), [2, 3, '1']],
];
@ -44,8 +46,13 @@ final class EndsWithTest extends RuleTestCase
[new EndsWith('foo'), 'faabarbaz'],
[new EndsWith('foo'), 'baabazfaa'],
[new EndsWith('foo'), 'baafoofaa'],
[new EndsWith('foo', 'bar'), 'foobaz'],
[new EndsWith('foo', 'bar'), ['foo', 'baz']],
[new EndsWith('1'), [1, '1', 3]],
[new EndsWith('1'), [2, 3, 1]],
// non-string inputs/values should not trigger warnings
[new EndsWith('foo'), 123],
[new EndsWith(123), 'foo'],
];
}
}

View file

@ -28,6 +28,8 @@ final class StartsWithTest extends RuleTestCase
[new StartsWith('foo'), ['foo', 'bar']],
[new StartsWith('foo'), 'foobarbaz'],
[new StartsWith('foo'), 'foobazfoo'],
[new StartsWith('foo', 'bar'), 'barbaz'],
[new StartsWith('foo', 'bar'), ['bar', 'baz']],
[new StartsWith('1'), ['1', 2, 3]],
];
}
@ -44,6 +46,8 @@ final class StartsWithTest extends RuleTestCase
[new StartsWith('foo'), 'faabarbaz'],
[new StartsWith('foo'), 'baabazfaa'],
[new StartsWith('foo'), 'baafoofaa'],
[new StartsWith('foo', 'bar'), 'bazfoo'],
[new StartsWith('foo', 'bar'), ['baz', 'foo']],
[new StartsWith('1'), [1, '1', 3]],
[new StartsWith('1'), [1, 2, 3]],
];

View file

@ -0,0 +1,48 @@
<?php
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
*/
declare(strict_types=1);
namespace Respect\Validation\Validators;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use Respect\Validation\Test\RuleTestCase;
#[Group('validator')]
#[CoversClass(Trimmed::class)]
final class TrimmedTest extends RuleTestCase
{
/** @return iterable<array{Trimmed, mixed}> */
public static function providerForValidInput(): iterable
{
return [
[new Trimmed(), 'foo'],
[new Trimmed(), 'foo bar'],
[new Trimmed(), "foo\tbar"],
[new Trimmed(), ''],
[new Trimmed('foo', 'bar'), 'bazqux'],
[new Trimmed('foo', 'bar'), 'oofbarf'],
];
}
/** @return iterable<array{Trimmed, mixed}> */
public static function providerForInvalidInput(): iterable
{
return [
[new Trimmed(), ' foo'],
[new Trimmed(), "foo\t"],
[new Trimmed(), "\u{200B}foo"],
[new Trimmed(), "foo\u{FEFF}"],
[new Trimmed(), 123],
[new Trimmed('foo', 'bar'), 'foobaz'],
[new Trimmed('foo', 'bar'), 'bazbar'],
[new Trimmed('foo', 'bar'), 'barbazfoo'],
];
}
}