diff --git a/README.md b/README.md index 707c599..3a7d9f3 100644 --- a/README.md +++ b/README.md @@ -196,29 +196,14 @@ final class SomeClass try { (new \CuyZ\Valinor\MapperBuilder()) - ->mapper() - ->map( - SomeClass::class, - ['someValue' => 'bar_baz'] - ); + ->mapper() + ->map( + SomeClass::class, + ['someValue' => 'bar_baz'] + ); } catch (\CuyZ\Valinor\Mapper\MappingError $error) { - $node = $error->node(); - - // The name of a node can be accessed - $name = $node->name(); - - // The logical path of a node contains dot separated names of its parents - $path = $node->path(); - - // The type of the node can be cast to string to enhance suggestion messages - $type = (string)$node->type(); - - // If the node is a branch, its children can be recursively accessed - foreach ($node->children() as $child) { - // Do something… - } - // Get flatten list of all messages through the whole nodes tree + $node = $error->node(); $messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node); // If only errors are wanted, they can be filtered @@ -232,36 +217,24 @@ try { } ``` -### Message customization / translation +### Message customization -When working with messages, it can sometimes be useful to customize the content -of a message — for instance to translate it. +The content of a message can be changed to fit custom use cases; it can contain +placeholders that will be replaced with useful information. -The helper class `\CuyZ\Valinor\Mapper\Tree\Message\MessageMapFormatter` can be -used to provide a list of new formats. It can be instantiated with an array -where each key represents either: +The placeholders below are always available; even more may be used depending +on the original message. -- The code of the message to be replaced -- The content of the message to be replaced -- The class name of the message to be replaced +| Placeholder | Description | +|----------------------|:-----------------------------------------------------| +| `{message_code}` | the code of the message | +| `{node_name}` | name of the node to which the message is bound | +| `{node_path}` | path of the node to which the message is bound | +| `{node_type}` | type of the node to which the message is bound | +| `{original_value}` | the source value that was given to the node | +| `{original_message}` | the original message before being customized | -If none of those is found, the content of the message will stay unchanged unless -a default one is given to the class. - -If one of these keys is found, the array entry will be used to replace the -content of the message. This entry can be either a plain text or a callable that -takes the message as a parameter and returns a string; it is for instance -advised to use a callable in cases where a translation service is used — to -avoid useless greedy operations. - -In any case, the content can contain placeholders that will automatically be -replaced by, in order: - -1. The original code of the message -2. The original content of the message -3. A string representation of the node type -4. The name of the node -5. The path of the node +Usage: ```php try { @@ -272,47 +245,152 @@ try { $node = $error->node(); $messages = new \CuyZ\Valinor\Mapper\Tree\Message\MessagesFlattener($node); - $formatter = (new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ - // Will match if the given message has this exact code - 'some_code' => 'new content / previous code was: %1$s', - - // Will match if the given message has this exact content - 'Some message content' => 'new content / previous message: %2$s', - - // Will match if the given message is an instance of `SomeError` - SomeError::class => ' - - Original code of the message: %1$s - - Original content of the message: %2$s - - Node type: %3$s - - Node name: %4$s - - Node path: %5$s - ', - - // A callback can be used to get access to the message instance - OtherError::class => function (NodeMessage $message): string { - if ((string)$message->type() === 'string|int') { - // … - } - - return 'Some message content'; - }, - - // For greedy operation, it is advised to use a lazy-callback - 'foo' => fn () => $this->translator->translate('foo.bar'), - ])) - ->defaultsTo('some default message') - // …or… - ->defaultsTo(fn () => $this->translator->translate('default_message')); - foreach ($messages as $message) { - echo $formatter->format($message); + if ($message->code() === 'some_code') { + $message = $message->withBody('new message / {original_message}'); + } + + echo $message; } } ``` +The messages are formatted using the [ICU library], enabling the placeholders to +use advanced syntax to perform proper translations, for instance currency +support. + +```php +try { + (new \CuyZ\Valinor\MapperBuilder())->mapper()->map('int<0, 100>', 1337); +} catch (\CuyZ\Valinor\Mapper\MappingError $error) { + $message = $error->node()->messages()[0]; + + if (is_numeric($message->value())) { + $message = $message->withBody( + 'Invalid amount {original_value, number, currency}' + ); + } + + // Invalid amount: $1,337.00 + echo $message->withLocale('en_US'); + + // Invalid amount: £1,337.00 + echo $message->withLocale('en_GB'); + + // Invalid amount: 1 337,00 € + echo $message->withLocale('fr_FR'); +} +``` + +See [ICU documentation] for more information on available syntax. + +If the `intl` extension is not installed, a shim will be available to replace +the placeholders, but it won't handle advanced syntax as described above. + +### Deeper message customization / translation + +For deeper message changes, formatters can be used — for instance to translate +content. + +#### Translation + +The formatter `TranslationMessageFormatter` can be used to translate the content +of messages. + +The library provides a list of all messages that can be returned; this list can +be filled or modified with custom translations. + +```php +\CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default() + // Create/override a single entry… + ->withTranslation('fr', 'some custom message', 'un message personnalisé') + // …or several entries. + ->withTranslations([ + 'some custom message' => [ + 'en' => 'Some custom message', + 'fr' => 'Un message personnalisé', + 'es' => 'Un mensaje personalizado', + ], + 'some other message' => [ + // … + ], + ]) + ->format($message); +``` + +#### Replacement map + +The formatter `MessageMapFormatter` can be used to provide a list of messages +replacements. It can be instantiated with an array where each key represents +either: + +- The code of the message to be replaced +- The body of the message to be replaced +- The class name of the message to be replaced + +If none of those is found, the content of the message will stay unchanged unless +a default one is given to the class. + +If one of these keys is found, the array entry will be used to replace the +content of the message. This entry can be either a plain text or a callable that +takes the message as a parameter and returns a string; it is for instance +advised to use a callable in cases where a custom translation service is used — +to avoid useless greedy operations. + +In any case, the content can contain placeholders as described +[above](#message-customization). + +```php +(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ + // Will match if the given message has this exact code + 'some_code' => 'New content / code: {message_code}', + + // Will match if the given message has this exact content + 'Some message content' => 'New content / previous: {original_message}', + + // Will match if the given message is an instance of `SomeError` + SomeError::class => 'New content / value: {original_value}', + + // A callback can be used to get access to the message instance + OtherError::class => function (NodeMessage $message): string { + if ($message->path() === 'foo.bar') { + return 'Some custom message'; + } + + return $message->body(); + }, + + // For greedy operation, it is advised to use a lazy-callback + 'foo' => fn () => $this->customTranslator->translate('foo.bar'), +])) + ->defaultsTo('some default message') + // …or… + ->defaultsTo(fn () => $this->customTranslator->translate('default_message')) + ->format($message); +``` + +#### Several formatters + +It is possible to join several formatters into one formatter by using the +`AggregateMessageFormatter`. This instance can then easily be injected in a +service that will handle messages. + +The formatters will be called in the same order they are given to the aggregate. + +```php +(new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\AggregateMessageFormatter( + new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\LocaleMessageFormatter('fr'), + new \CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter([ + // … + ], + \CuyZ\Valinor\Mapper\Tree\Message\Formatter\TranslationMessageFormatter::default(), +))->format($message) +``` + ### Source -Any source can be given to the mapper, be it an array, some json, yaml or even a file: +Any source can be given to the mapper, be it an array, some json, yaml or even a +file: ```php function map($source) { @@ -895,3 +973,7 @@ includes: [Webmozart Assert]: https://github.com/webmozarts/assert [link-packagist]: https://packagist.org/packages/cuyz/valinor + +[ICU library]: https://unicode-org.github.io/icu/ + +[ICU documentation]: https://unicode-org.github.io/icu/userguide/format_parse/messages/ diff --git a/infection.json.dist b/infection.json.dist index 04acff8..75ba3b5 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -5,6 +5,7 @@ "src" ] }, + "tmpDir": "var/cache/infection", "logs": { "text": "var/infection/infection.log", "summary": "var/infection/summary.log", diff --git a/src/Mapper/Object/Arguments.php b/src/Mapper/Object/Arguments.php index 5a51881..254ba46 100644 --- a/src/Mapper/Object/Arguments.php +++ b/src/Mapper/Object/Arguments.php @@ -41,14 +41,14 @@ final class Arguments implements IteratorAggregate, Countable $name = $argument->name(); $type = $argument->type(); - $signature = TypeHelper::containsObject($type) ? '?' : $type; + $signature = TypeHelper::dump($type, false); return $argument->isRequired() ? "$name: $signature" : "$name?: $signature"; }, $this->arguments ); - return 'array{' . implode(', ', $parameters) . '}'; + return '`array{' . implode(', ', $parameters) . '}`'; } public function count(): int diff --git a/src/Mapper/Object/DateTimeObjectBuilder.php b/src/Mapper/Object/DateTimeObjectBuilder.php index c98da63..ecd0d4a 100644 --- a/src/Mapper/Object/DateTimeObjectBuilder.php +++ b/src/Mapper/Object/DateTimeObjectBuilder.php @@ -84,7 +84,7 @@ final class DateTimeObjectBuilder implements ObjectBuilder if (! $date) { // @PHP8.0 use throw exception expression - throw new CannotParseToDateTime((string)$datetime); + throw new CannotParseToDateTime($datetime); } return $date; diff --git a/src/Mapper/Object/Exception/CannotFindObjectBuilder.php b/src/Mapper/Object/Exception/CannotFindObjectBuilder.php index 5803c51..c26d55b 100644 --- a/src/Mapper/Object/Exception/CannotFindObjectBuilder.php +++ b/src/Mapper/Object/Exception/CannotFindObjectBuilder.php @@ -6,48 +6,65 @@ namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Factory\SuitableObjectBuilderNotFound; use CuyZ\Valinor\Mapper\Object\ObjectBuilder; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; use function array_keys; use function count; -use function implode; use function ksort; /** @api */ -final class CannotFindObjectBuilder extends RuntimeException implements Message, SuitableObjectBuilderNotFound +final class CannotFindObjectBuilder extends RuntimeException implements TranslatableMessage, SuitableObjectBuilderNotFound { + private string $body = 'Value {value} does not match any of {allowed_types}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source * @param non-empty-list $builders */ public function __construct($source, array $builders) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + 'allowed_types' => (function () use ($builders) { + $signatures = []; + $sortedSignatures = []; - $signatures = []; - $sortedSignatures = []; + foreach ($builders as $builder) { + $arguments = $builder->describeArguments(); + $count = count($arguments); + $signature = $arguments->signature(); - foreach ($builders as $builder) { - $arguments = $builder->describeArguments(); - $count = count($arguments); - $signature = $arguments->signature(); + $signatures[$count][$signature] = null; + } - $signatures[$count][$signature] = null; - } + ksort($signatures); - ksort($signatures); + foreach ($signatures as $list) { + foreach (array_keys($list) as $signature) { + $sortedSignatures[] = $signature; + } + } - foreach ($signatures as $list) { - foreach (array_keys($list) as $signature) { - $sortedSignatures[] = $signature; - } - } + return implode(', ', $sortedSignatures); + })(), + ]; - parent::__construct( - "Value $value does not match any of `" . implode('`, `', $sortedSignatures) . '`.', - 1642183169 - ); + parent::__construct(StringFormatter::for($this), 1642183169); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/CannotParseToDateTime.php b/src/Mapper/Object/Exception/CannotParseToDateTime.php index 1e9c082..b6b2c26 100644 --- a/src/Mapper/Object/Exception/CannotParseToDateTime.php +++ b/src/Mapper/Object/Exception/CannotParseToDateTime.php @@ -4,20 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class CannotParseToDateTime extends RuntimeException implements Message +final class CannotParseToDateTime extends RuntimeException implements TranslatableMessage { - public function __construct(string $datetime) - { - $datetime = ValueDumper::dump($datetime); + private string $body = 'Value {value} does not match a valid date format.'; - parent::__construct( - "Impossible to parse date with value $datetime.", - 1630686564 - ); + /** @var array */ + private array $parameters; + + /** + * @param string|int $datetime + */ + public function __construct($datetime) + { + $this->parameters = [ + 'value' => ValueDumper::dump($datetime), + ]; + + parent::__construct(StringFormatter::for($this), 1630686564); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/InvalidSourceForInterface.php b/src/Mapper/Object/Exception/InvalidSourceForInterface.php index 2886c83..a1f993e 100644 --- a/src/Mapper/Object/Exception/InvalidSourceForInterface.php +++ b/src/Mapper/Object/Exception/InvalidSourceForInterface.php @@ -4,23 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidSourceForInterface extends RuntimeException implements Message +final class InvalidSourceForInterface extends RuntimeException implements TranslatableMessage { + private string $body = 'Invalid value {value}: it must be an array.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source) { - $type = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + ]; - parent::__construct( - "Invalid value $type, it must be an iterable.", - 1645283485 - ); + parent::__construct(StringFormatter::for($this), 1645283485); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/InvalidSourceForObject.php b/src/Mapper/Object/Exception/InvalidSourceForObject.php index 558efe7..fab42ee 100644 --- a/src/Mapper/Object/Exception/InvalidSourceForObject.php +++ b/src/Mapper/Object/Exception/InvalidSourceForObject.php @@ -5,23 +5,39 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Arguments; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidSourceForObject extends RuntimeException implements Message +final class InvalidSourceForObject extends RuntimeException implements TranslatableMessage { + private string $body = 'Value {value} does not match type {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source, Arguments $arguments) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + 'expected_type' => $arguments->signature(), + ]; - parent::__construct( - "Value $value does not match `{$arguments->signature()}`.", - 1632903281 - ); + parent::__construct(StringFormatter::for($this), 1632903281); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php index ab28913..fcded32 100644 --- a/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php +++ b/src/Mapper/Object/Exception/SeveralObjectBuildersFound.php @@ -5,23 +5,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Object\Exception; use CuyZ\Valinor\Mapper\Object\Factory\SuitableObjectBuilderNotFound; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class SeveralObjectBuildersFound extends RuntimeException implements Message, SuitableObjectBuilderNotFound +final class SeveralObjectBuildersFound extends RuntimeException implements TranslatableMessage, SuitableObjectBuilderNotFound { + private string $body = 'Invalid value {value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $source */ public function __construct($source) { - $value = ValueDumper::dump($source); + $this->parameters = [ + 'value' => ValueDumper::dump($source), + ]; - parent::__construct( - "Value $value is not accepted.", - 1642787246 - ); + parent::__construct(StringFormatter::for($this), 1642787246); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/CannotCastToScalarValue.php b/src/Mapper/Tree/Exception/CannotCastToScalarValue.php index 2ae5e7f..9c78f1c 100644 --- a/src/Mapper/Tree/Exception/CannotCastToScalarValue.php +++ b/src/Mapper/Tree/Exception/CannotCastToScalarValue.php @@ -4,26 +4,45 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\ScalarType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class CannotCastToScalarValue extends RuntimeException implements Message +final class CannotCastToScalarValue extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, ScalarType $type) { - if ($value === null || $value === []) { - $message = "Cannot be empty and must be filled with a value of type `$type`."; - } else { - $value = ValueDumper::dump($value); - $message = "Cannot cast $value to `$type`."; - } + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct($message, 1618736242); + $this->body = $value === null || $value === [] + ? 'Cannot be empty and must be filled with a value matching type {expected_type}.' + : 'Cannot cast {value} to {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1618736242); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidEnumValue.php b/src/Mapper/Tree/Exception/InvalidEnumValue.php index 865b5c9..3e6dbaa 100644 --- a/src/Mapper/Tree/Exception/InvalidEnumValue.php +++ b/src/Mapper/Tree/Exception/InvalidEnumValue.php @@ -5,7 +5,8 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; use BackedEnum; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; use UnitEnum; @@ -14,27 +15,41 @@ use function array_map; use function implode; /** @api */ -final class InvalidEnumValue extends RuntimeException implements Message +final class InvalidEnumValue extends RuntimeException implements TranslatableMessage { + private string $body = 'Value {value} does not match any of {allowed_values}.'; + + /** @var array */ + private array $parameters; + /** * @param class-string $enumName * @param mixed $value */ public function __construct(string $enumName, $value) { - $values = array_map( - static function (UnitEnum $case) { - return ValueDumper::dump($case instanceof BackedEnum ? $case->value : $case->name); - }, - $enumName::cases() - ); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'allowed_values' => (function () use ($enumName) { + $values = array_map( + fn (UnitEnum $case) => ValueDumper::dump($case instanceof BackedEnum ? $case->value : $case->name), + $enumName::cases() + ); - $values = implode(', ', $values); - $value = ValueDumper::dump($value); + return implode(', ', $values); + })(), + ]; - parent::__construct( - "Invalid value $value, it must be one of $values.", - 1633093113 - ); + parent::__construct(StringFormatter::for($this), 1633093113); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php b/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php index 79a96b7..0dc5eb9 100644 --- a/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php +++ b/src/Mapper/Tree/Exception/InvalidInterfaceResolverReturnType.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Utility\Polyfill; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; diff --git a/src/Mapper/Tree/Exception/InvalidNodeValue.php b/src/Mapper/Tree/Exception/InvalidNodeValue.php index e8b054f..177ab85 100644 --- a/src/Mapper/Tree/Exception/InvalidNodeValue.php +++ b/src/Mapper/Tree/Exception/InvalidNodeValue.php @@ -4,25 +4,45 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidNodeValue extends RuntimeException implements Message +final class InvalidNodeValue extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, Type $type) { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($type) - ? "Value $value is not accepted." - : "Value $value does not match expected `$type`."; + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct($message, 1630678334); + $this->body = TypeHelper::containsObject($type) + ? 'Invalid value {value}.' + : 'Value {value} does not match type {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1630678334); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/InvalidTraversableKey.php b/src/Mapper/Tree/Exception/InvalidTraversableKey.php index f9c7960..9429265 100644 --- a/src/Mapper/Tree/Exception/InvalidTraversableKey.php +++ b/src/Mapper/Tree/Exception/InvalidTraversableKey.php @@ -4,24 +4,41 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\ArrayKeyType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class InvalidTraversableKey extends RuntimeException implements Message +final class InvalidTraversableKey extends RuntimeException implements TranslatableMessage { + private string $body = 'Key {key} does not match type {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param string|int $key */ public function __construct($key, ArrayKeyType $type) { - $key = ValueDumper::dump($key); + $this->parameters = [ + 'key' => ValueDumper::dump($key), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct( - "Invalid key $key, it must be of type `$type`.", - 1630946163 - ); + parent::__construct(StringFormatter::for($this), 1630946163); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php b/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php index a9fa432..d1047e8 100644 --- a/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php +++ b/src/Mapper/Tree/Exception/ShapedArrayElementMissing.php @@ -4,18 +4,37 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\ShapedArrayElement; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use RuntimeException; /** @api */ -final class ShapedArrayElementMissing extends RuntimeException implements Message +final class ShapedArrayElementMissing extends RuntimeException implements TranslatableMessage { + private string $body = 'Missing element {element} matching type {expected_type}.'; + + /** @var array */ + private array $parameters; + public function __construct(ShapedArrayElement $element) { - parent::__construct( - "Missing element `{$element->key()}` of type `{$element->type()}`.", - 1631613641 - ); + $this->parameters = [ + 'element' => "`{$element->key()}`", + 'expected_type' => TypeHelper::dump($element->type()), + ]; + + parent::__construct(StringFormatter::for($this), 1631613641); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Exception/SourceMustBeIterable.php b/src/Mapper/Tree/Exception/SourceMustBeIterable.php index d9cc38e..d224c93 100644 --- a/src/Mapper/Tree/Exception/SourceMustBeIterable.php +++ b/src/Mapper/Tree/Exception/SourceMustBeIterable.php @@ -4,31 +4,51 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ -final class SourceMustBeIterable extends RuntimeException implements Message +final class SourceMustBeIterable extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, Type $type) { + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; + if ($value === null) { - $message = TypeHelper::containsObject($type) + $this->body = TypeHelper::containsObject($type) ? 'Cannot be empty.' - : "Cannot be empty and must be filled with a value matching `$type`."; + : 'Cannot be empty and must be filled with a value matching type {expected_type}.'; } else { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($type) - ? "Value $value is not accepted." - : "Value $value does not match expected `$type`."; + $this->body = TypeHelper::containsObject($type) + ? 'Invalid value {value}.' + : 'Value {value} does not match type {expected_type}.'; } - parent::__construct($message, 1618739163); + parent::__construct(StringFormatter::for($this), 1618739163); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Mapper/Tree/Message/DefaultMessage.php b/src/Mapper/Tree/Message/DefaultMessage.php new file mode 100644 index 0000000..4605eda --- /dev/null +++ b/src/Mapper/Tree/Message/DefaultMessage.php @@ -0,0 +1,78 @@ + [ + 'en' => 'Value {value} does not match expected {expected_value}.', + ], + 'Value {value} does not match any of {allowed_values}.' => [ + 'en' => 'Value {value} does not match any of {allowed_values}.', + ], + 'Value {value} does not match any of {allowed_types}.' => [ + 'en' => 'Value {value} does not match any of {allowed_types}.', + ], + 'Value {value} does not match type {expected_type}.' => [ + 'en' => 'Value {value} does not match type {expected_type}.', + ], + 'Value {value} does not match float value {expected_value}.' => [ + 'en' => 'Value {value} does not match float value {expected_value}.', + ], + 'Value {value} does not match integer value {expected_value}.' => [ + 'en' => 'Value {value} does not match integer value {expected_value}.', + ], + 'Value {value} does not match string value {expected_value}.' => [ + 'en' => 'Value {value} does not match string value {expected_value}.', + ], + 'Invalid value {value}.' => [ + 'en' => 'Invalid value {value}.', + ], + 'Invalid value {value}: it must be an array.' => [ + 'en' => 'Invalid value {value}: it must be an array.', + ], + 'Invalid value {value}: it must be an integer between {min} and {max}.' => [ + 'en' => 'Invalid value {value}: it must be an integer between {min} and {max}.', + ], + 'Invalid value {value}: it must be a negative integer.' => [ + 'en' => 'Invalid value {value}: it must be a negative integer.', + ], + 'Invalid value {value}: it must be a positive integer.' => [ + 'en' => 'Invalid value {value}: it must be a positive integer.', + ], + 'Cannot cast {value} to {expected_type}.' => [ + 'en' => 'Cannot cast {value} to {expected_type}.', + ], + 'Cannot be empty.' => [ + 'en' => 'Cannot be empty.', + ], + 'Cannot be empty and must be filled with a valid string value.' => [ + 'en' => 'Cannot be empty and must be filled with a valid string value.', + ], + 'Cannot be empty and must be filled with a value matching type {expected_type}.' => [ + 'en' => 'Cannot be empty and must be filled with a value matching type {expected_type}.', + ], + 'Key {key} does not match type {expected_type}.' => [ + 'en' => 'Key {key} does not match type {expected_type}.', + ], + 'Missing element {element} matching type {expected_type}.' => [ + 'en' => 'Missing element {element} matching type {expected_type}.', + ], + 'Value {value} does not match a valid date format.' => [ + 'en' => 'Value {value} does not match a valid date format.', + ], + 'Invalid class string {value}, it must be one of {expected_class_strings}.' => [ + 'en' => 'Invalid class string {value}, it must be one of {expected_class_strings}.', + ], + 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.' => [ + 'en' => 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.', + ], + 'Invalid class string {value}.' => [ + 'en' => 'Invalid class string {value}.', + ], + ]; +} diff --git a/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php new file mode 100644 index 0000000..d4d3038 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/AggregateMessageFormatter.php @@ -0,0 +1,28 @@ +formatters = $formatters; + } + + public function format(NodeMessage $message): NodeMessage + { + foreach ($this->formatters as $formatter) { + $message = $formatter->format($message); + } + + return $message; + } +} diff --git a/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php new file mode 100644 index 0000000..d47ab3a --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/LocaleMessageFormatter.php @@ -0,0 +1,23 @@ +locale = $locale; + } + + public function format(NodeMessage $message): NodeMessage + { + return $message->withLocale($this->locale); + } +} diff --git a/src/Mapper/Tree/Message/Formatter/MessageFormatter.php b/src/Mapper/Tree/Message/Formatter/MessageFormatter.php index d0de234..8df6910 100644 --- a/src/Mapper/Tree/Message/Formatter/MessageFormatter.php +++ b/src/Mapper/Tree/Message/Formatter/MessageFormatter.php @@ -9,5 +9,5 @@ use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; /** @api */ interface MessageFormatter { - public function format(NodeMessage $message): string; + public function format(NodeMessage $message): NodeMessage; } diff --git a/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php b/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php index 399800f..4d02f24 100644 --- a/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php +++ b/src/Mapper/Tree/Message/Formatter/MessageMapFormatter.php @@ -14,7 +14,7 @@ use function is_callable; * * The constructor parameter is an array where each key represents either: * - The code of the message to be replaced - * - The content of the message to be replaced + * - The body of the message to be replaced * - The class name of the message to be replaced * * If none of those is found, the content of the message will stay unchanged @@ -23,38 +23,29 @@ use function is_callable; * If one of these keys is found, the array entry will be used to replace the * content of the message. This entry can be either a plain text or a callable * that takes the message as a parameter and returns a string; it is for - * instance advised to use a callable in cases where a translation service is - * used — to avoid useless greedy operations. - * - * In any case, the content can contain placeholders that can be used the same - * way as @see \CuyZ\Valinor\Mapper\Tree\Message\NodeMessage::format() + * instance advised to use a callable in cases where a custom translation + * service is used — to avoid useless greedy operations. * * See usage examples below: * * ```php * $formatter = (new MessageMapFormatter([ * // Will match if the given message has this exact code - * 'some_code' => 'new content / previous code was: %1$s', + * 'some_code' => 'New content / code: {message_code}', * * // Will match if the given message has this exact content - * 'Some message content' => 'new content / previous message: %2$s', + * 'Some message content' => 'New content / previous: {original_message}', * * // Will match if the given message is an instance of `SomeError` - * SomeError::class => ' - * - Original code of the message: %1$s - * - Original content of the message: %2$s - * - Node type: %3$s - * - Node name: %4$s - * - Node path: %5$s - * ', + * SomeError::class => 'New content / value: {original_value}', * * // A callback can be used to get access to the message instance * OtherError::class => function (NodeMessage $message): string { - * if ((string)$message->type() === 'string|int') { - * // … + * if ($message->path() === 'foo.bar') { + * return 'Some custom message'; * } * - * return 'Some message content'; + * return $message->body(); * }, * * // For greedy operation, it is advised to use a lazy-callback @@ -85,12 +76,15 @@ final class MessageMapFormatter implements MessageFormatter $this->map = $map; } - public function format(NodeMessage $message): string + public function format(NodeMessage $message): NodeMessage { $target = $this->target($message); - $text = is_callable($target) ? $target($message) : $target; - return $message->format($text); + if ($target) { + return $message->withBody(is_callable($target) ? $target($message) : $target); + } + + return $message; } /** @@ -105,14 +99,14 @@ final class MessageMapFormatter implements MessageFormatter } /** - * @return string|callable(NodeMessage): string + * @return false|string|callable(NodeMessage): string */ private function target(NodeMessage $message) { return $this->map[$message->code()] - ?? $this->map[(string)$message] + ?? $this->map[$message->body()] ?? $this->map[get_class($message->originalMessage())] ?? $this->default - ?? (string)$message; + ?? false; } } diff --git a/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php new file mode 100644 index 0000000..93692f0 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatter.php @@ -0,0 +1,54 @@ +values = $values; + } + + public function format(NodeMessage $message): NodeMessage + { + $originalMessage = $message->originalMessage(); + + $body = sprintf($message->body(), ...$this->values ?: [ + $message->code(), + $originalMessage instanceof Throwable ? $originalMessage->getMessage() : $originalMessage->__toString(), + TypeHelper::dump($message->type()), + $message->name(), + $message->path(), + ]); + + return $message->withBody($body); + } +} diff --git a/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php b/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php new file mode 100644 index 0000000..5fff633 --- /dev/null +++ b/src/Mapper/Tree/Message/Formatter/TranslationMessageFormatter.php @@ -0,0 +1,85 @@ +> */ + private array $translations = []; + + /** + * Returns an instance of the class with the default translations provided + * by the library. + */ + public static function default(): self + { + $instance = new self(); + $instance->translations = DefaultMessage::TRANSLATIONS; + + return $instance; + } + + /** + * Creates or overrides a single translation. + * + * ```php + * (TranslationMessageFormatter::default())->withTranslation( + * 'fr', + * 'Invalid value {value}.', + * 'Valeur invalide {value}.', + * ); + * ``` + */ + public function withTranslation(string $locale, string $original, string $translation): self + { + $clone = clone $this; + $clone->translations[$original][$locale] = $translation; + + return $clone; + } + + /** + * Creates or overrides a list of translations. + * + * The given array consists of messages to be translated and for each one a + * list of locales with their associated translations. + * + * ```php + * (TranslationMessageFormatter::default())->withTranslations([ + * 'Invalid value {value}.' => [ + * 'fr' => 'Valeur invalide {value}.', + * 'es' => 'Valor inválido {value}.', + * ], + * 'Some custom message' => [ + * // … + * ], + * ]); + * ``` + * + * @param array> $translations + */ + public function withTranslations(array $translations): self + { + $clone = clone $this; + $clone->translations = array_replace_recursive($this->translations, $translations); + + return $clone; + } + + public function format(NodeMessage $message): NodeMessage + { + $body = $this->translations[$message->body()][$message->locale()] ?? null; + + if ($body) { + return $message->withBody($body); + } + + return $message; + } +} diff --git a/src/Mapper/Tree/Message/NodeMessage.php b/src/Mapper/Tree/Message/NodeMessage.php index 01a30a4..908855e 100644 --- a/src/Mapper/Tree/Message/NodeMessage.php +++ b/src/Mapper/Tree/Message/NodeMessage.php @@ -5,78 +5,82 @@ declare(strict_types=1); namespace CuyZ\Valinor\Mapper\Tree\Message; use CuyZ\Valinor\Definition\Attributes; -use CuyZ\Valinor\Mapper\Tree\Node; +use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Type\Type; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; +use CuyZ\Valinor\Utility\ValueDumper; use Throwable; -use function sprintf; - /** @api */ final class NodeMessage implements Message, HasCode { - private Node $node; + private Shell $shell; private Message $message; - public function __construct(Node $node, Message $message) + private string $body; + + private string $locale = StringFormatter::DEFAULT_LOCALE; + + public function __construct(Shell $shell, Message $message) { - $this->node = $node; + $this->shell = $shell; $this->message = $message; + + if ($this->message instanceof TranslatableMessage) { + $this->body = $this->message->body(); + } elseif ($this->message instanceof Throwable) { + $this->body = $this->message->getMessage(); + } else { + $this->body = (string)$this->message; + } } - /** - * Performs a placeholders replace operation on the given content. - * - * The values to be replaced will be the ones given as second argument; if - * none is given these values will be used instead, in order: - * - * 1. The original code of this message - * 2. The original content of this message - * 3. A string representation of the node type - * 4. The name of the node - * 5. The path of the node - * - * See usage examples below: - * - * ```php - * $content = $message->format('the previous code was: %1$s'); - * - * $content = $message->format( - * '%1$s / new message content (type: %2$s)', - * 'some parameter', - * $message->type(), - * ); - * ``` - */ - public function format(string $content, string ...$values): string + public function withLocale(string $locale): self { - return sprintf($content, ...$values ?: [ - $this->code(), - (string)$this, - (string)$this->type(), - $this->name(), - $this->path(), - ]); + $clone = clone $this; + $clone->locale = $locale; + + return $clone; + } + + public function locale(): string + { + return $this->locale; + } + + public function withBody(string $body): self + { + $clone = clone $this; + $clone->body = $body; + + return $clone; + } + + public function body(): string + { + return $this->body; } public function name(): string { - return $this->node->name(); + return $this->shell->name(); } public function path(): string { - return $this->node->path(); + return $this->shell->path(); } public function type(): Type { - return $this->node->type(); + return $this->shell->type(); } public function attributes(): Attributes { - return $this->node->attributes(); + return $this->shell->attributes(); } /** @@ -84,11 +88,7 @@ final class NodeMessage implements Message, HasCode */ public function value() { - if (! $this->node->isValid()) { - return null; - } - - return $this->node->value(); + return $this->shell->value(); } public function originalMessage(): Message @@ -116,10 +116,27 @@ final class NodeMessage implements Message, HasCode public function __toString(): string { - if ($this->message instanceof Throwable) { - return $this->message->getMessage(); + return StringFormatter::format($this->locale, $this->body, $this->parameters()); + } + + /** + * @return array + */ + private function parameters(): array + { + $parameters = [ + 'message_code' => $this->code(), + 'node_name' => $this->shell->name(), + 'node_path' => $this->shell->path(), + 'node_type' => TypeHelper::dump($this->shell->type()), + 'original_value' => ValueDumper::dump($this->shell->value()), + 'original_message' => $this->message instanceof Throwable ? $this->message->getMessage() : $this->message->__toString(), + ]; + + if ($this->message instanceof TranslatableMessage) { + $parameters += $this->message->parameters(); } - return (string)$this->message; + return $parameters; } } diff --git a/src/Mapper/Tree/Message/TranslatableMessage.php b/src/Mapper/Tree/Message/TranslatableMessage.php new file mode 100644 index 0000000..6f87291 --- /dev/null +++ b/src/Mapper/Tree/Message/TranslatableMessage.php @@ -0,0 +1,16 @@ + + */ + public function parameters(): array; +} diff --git a/src/Mapper/Tree/Node.php b/src/Mapper/Tree/Node.php index 605f161..53aada3 100644 --- a/src/Mapper/Tree/Node.php +++ b/src/Mapper/Tree/Node.php @@ -139,10 +139,10 @@ final class Node public function withMessage(Message $message): self { - $message = new NodeMessage($this, $message); + $message = new NodeMessage($this->shell, $message); $clone = clone $this; - $clone->messages = [...$this->messages, $message]; + $clone->messages[] = $message; $clone->valid = $clone->valid && ! $message->isError(); return $clone; diff --git a/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php b/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php index 795046c..808d03b 100644 --- a/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php +++ b/src/Type/Resolver/Exception/CannotResolveTypeFromUnion.php @@ -4,27 +4,52 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Resolver\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; +use function array_map; use function implode; /** @api */ -final class CannotResolveTypeFromUnion extends RuntimeException implements Message +final class CannotResolveTypeFromUnion extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct(UnionType $unionType, $value) { - $value = ValueDumper::dump($value); - $message = TypeHelper::containsObject($unionType) - ? "Value $value is not accepted." - : "Value $value does not match any of `" . implode('`, `', $unionType->types()) . "`."; + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'allowed_types' => implode( + ', ', + // @PHP8.1 First-class callable syntax + array_map([TypeHelper::class, 'dump'], $unionType->types()) + ), + ]; - parent::__construct($message, 1607027306); + $this->body = TypeHelper::containsObject($unionType) + ? 'Invalid value {value}.' + : 'Value {value} does not match any of {allowed_types}.'; + + parent::__construct(StringFormatter::for($this), 1607027306); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php b/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php index 8860804..7b7308e 100644 --- a/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php +++ b/src/Type/Resolver/Exception/UnionTypeDoesNotAllowNull.php @@ -4,20 +4,40 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Resolver\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\TypeHelper; use RuntimeException; /** @api */ -final class UnionTypeDoesNotAllowNull extends RuntimeException implements Message +final class UnionTypeDoesNotAllowNull extends RuntimeException implements TranslatableMessage { + private string $body; + + /** @var array */ + private array $parameters; + public function __construct(UnionType $unionType) { - $message = TypeHelper::containsObject($unionType) - ? 'Cannot be empty.' - : "Cannot be empty and must be filled with a value matching `$unionType`."; + $this->parameters = [ + 'expected_type' => TypeHelper::dump($unionType), + ]; - parent::__construct($message, 1618742357); + $this->body = TypeHelper::containsObject($unionType) + ? 'Cannot be empty.' + : 'Cannot be empty and must be filled with a value matching type {expected_type}.'; + + parent::__construct(StringFormatter::for($this), 1618742357); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/CannotCastValue.php b/src/Type/Types/Exception/CannotCastValue.php index 7a2806b..68ea478 100644 --- a/src/Type/Types/Exception/CannotCastValue.php +++ b/src/Type/Types/Exception/CannotCastValue.php @@ -5,22 +5,39 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\ScalarType; +use CuyZ\Valinor\Utility\String\StringFormatter; +use CuyZ\Valinor\Utility\TypeHelper; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class CannotCastValue extends RuntimeException implements CastError { + private string $body = 'Cannot cast {value} to {expected_type}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ public function __construct($value, ScalarType $type) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_type' => TypeHelper::dump($type), + ]; - parent::__construct( - "Cannot cast $value to `$type`.", - 1603216198 - ); + parent::__construct(StringFormatter::for($this), 1603216198); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/CastError.php b/src/Type/Types/Exception/CastError.php index 7952036..f85ded3 100644 --- a/src/Type/Types/Exception/CastError.php +++ b/src/Type/Types/Exception/CastError.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; -use CuyZ\Valinor\Mapper\Tree\Message\Message; +use CuyZ\Valinor\Mapper\Tree\Message\TranslatableMessage; use Throwable; /** @internal */ -interface CastError extends Throwable, Message +interface CastError extends Throwable, TranslatableMessage { } diff --git a/src/Type/Types/Exception/InvalidClassString.php b/src/Type/Types/Exception/InvalidClassString.php index 5357795..b2173a6 100644 --- a/src/Type/Types/Exception/InvalidClassString.php +++ b/src/Type/Types/Exception/InvalidClassString.php @@ -7,6 +7,7 @@ namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\ObjectType; use CuyZ\Valinor\Type\Type; use CuyZ\Valinor\Type\Types\UnionType; +use CuyZ\Valinor\Utility\String\StringFormatter; use LogicException; use function count; @@ -15,27 +16,51 @@ use function implode; /** @api */ final class InvalidClassString extends LogicException implements CastError { + private string $body; + + /** @var array */ + private array $parameters; + /** * @param ObjectType|UnionType|null $type */ public function __construct(string $raw, ?Type $type) { - $types = []; - if ($type instanceof ObjectType) { - $types = [$type]; + $expected = [$type]; } elseif ($type instanceof UnionType) { - $types = $type->types(); + $expected = $type->types(); + } else { + $expected = []; } - $message = "Invalid class string `$raw`."; + $expectedString = count($expected) !== 0 + ? '`' . implode('`, `', $expected) . '`' + : ''; - if (count($types) > 1) { - $message = "Invalid class string `$raw`, it must be one of `" . implode('`, `', $types) . "`."; - } elseif (count($types) === 1) { - $message = "Invalid class string `$raw`, it must be a subtype of `$type`."; + $this->parameters = [ + 'value' => "`$raw`", + 'expected_class_strings' => $expectedString, + ]; + + if (count($expected) > 1) { + $this->body = 'Invalid class string {value}, it must be one of {expected_class_strings}.'; + } elseif (count($expected) === 1) { + $this->body = 'Invalid class string {value}, it must be a subtype of {expected_class_strings}.'; + } else { + $this->body = 'Invalid class string {value}.'; } - parent::__construct($message, 1608132562); + parent::__construct(StringFormatter::for($this), 1608132562); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidEmptyStringValue.php b/src/Type/Types/Exception/InvalidEmptyStringValue.php index fba3a01..60b4691 100644 --- a/src/Type/Types/Exception/InvalidEmptyStringValue.php +++ b/src/Type/Types/Exception/InvalidEmptyStringValue.php @@ -4,16 +4,26 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidEmptyStringValue extends RuntimeException implements CastError { + private string $body = 'Cannot be empty and must be filled with a valid string value.'; + public function __construct() { - parent::__construct( - "Cannot be empty and must be filled with a valid string value.", - 1632925312 - ); + parent::__construct(StringFormatter::for($this), 1632925312); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return []; } } diff --git a/src/Type/Types/Exception/InvalidFloatValue.php b/src/Type/Types/Exception/InvalidFloatValue.php index e202e59..408481b 100644 --- a/src/Type/Types/Exception/InvalidFloatValue.php +++ b/src/Type/Types/Exception/InvalidFloatValue.php @@ -4,16 +4,34 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidFloatValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(float $value, float $expected) { - parent::__construct( - "Value $value does not match expected $expected.", - 1652110115 - ); + $this->parameters = [ + 'value' => (string)$value, + 'expected_value' => (string)$expected, + ]; + + parent::__construct(StringFormatter::for($this), 1652110115); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidFloatValueType.php b/src/Type/Types/Exception/InvalidFloatValueType.php index 9a80cb2..262a660 100644 --- a/src/Type/Types/Exception/InvalidFloatValueType.php +++ b/src/Type/Types/Exception/InvalidFloatValueType.php @@ -4,22 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidFloatValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match float value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, float $floatValue) + public function __construct($value, float $expected) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => (string)$expected, + ]; - parent::__construct( - "Value $value does not match float value $floatValue.", - 1652110003 - ); + parent::__construct(StringFormatter::for($this), 1652110003); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerRangeValue.php b/src/Type/Types/Exception/InvalidIntegerRangeValue.php index e459788..daf3f9a 100644 --- a/src/Type/Types/Exception/InvalidIntegerRangeValue.php +++ b/src/Type/Types/Exception/InvalidIntegerRangeValue.php @@ -5,16 +5,35 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; use CuyZ\Valinor\Type\Types\IntegerRangeType; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidIntegerRangeValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be an integer between {min} and {max}.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value, IntegerRangeType $type) { - parent::__construct( - "Invalid value $value: it must be an integer between {$type->min()} and {$type->max()}.", - 1638785150 - ); + $this->parameters = [ + 'value' => (string)$value, + 'min' => (string)$type->min(), + 'max' => (string)$type->max(), + ]; + + parent::__construct(StringFormatter::for($this), 1638785150); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerValue.php b/src/Type/Types/Exception/InvalidIntegerValue.php index 43c94a3..dcd64b1 100644 --- a/src/Type/Types/Exception/InvalidIntegerValue.php +++ b/src/Type/Types/Exception/InvalidIntegerValue.php @@ -4,16 +4,34 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidIntegerValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value, int $expected) { - parent::__construct( - "Value $value does not match expected $expected.", - 1631090798 - ); + $this->parameters = [ + 'value' => (string)$value, + 'expected_value' => (string)$expected, + ]; + + parent::__construct(StringFormatter::for($this), 1631090798); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidIntegerValueType.php b/src/Type/Types/Exception/InvalidIntegerValueType.php index eb471cd..06a022f 100644 --- a/src/Type/Types/Exception/InvalidIntegerValueType.php +++ b/src/Type/Types/Exception/InvalidIntegerValueType.php @@ -4,22 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidIntegerValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match integer value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, int $integerValue) + public function __construct($value, int $expected) { - $value = ValueDumper::dump($value); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => (string)$expected, + ]; - parent::__construct( - "Value $value does not match integer value $integerValue.", - 1631267159 - ); + parent::__construct(StringFormatter::for($this), 1631267159); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidNegativeIntegerValue.php b/src/Type/Types/Exception/InvalidNegativeIntegerValue.php index c4d1079..5515ea5 100644 --- a/src/Type/Types/Exception/InvalidNegativeIntegerValue.php +++ b/src/Type/Types/Exception/InvalidNegativeIntegerValue.php @@ -4,16 +4,33 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidNegativeIntegerValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be a negative integer.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value) { - parent::__construct( - "Invalid value $value: it must be a negative integer.", - 1632923705 - ); + $this->parameters = [ + 'value' => (string)$value, + ]; + + parent::__construct(StringFormatter::for($this), 1632923705); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidPositiveIntegerValue.php b/src/Type/Types/Exception/InvalidPositiveIntegerValue.php index 9e1fc0b..106ea13 100644 --- a/src/Type/Types/Exception/InvalidPositiveIntegerValue.php +++ b/src/Type/Types/Exception/InvalidPositiveIntegerValue.php @@ -4,16 +4,33 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use RuntimeException; /** @api */ final class InvalidPositiveIntegerValue extends RuntimeException implements CastError { + private string $body = 'Invalid value {value}: it must be a positive integer.'; + + /** @var array */ + private array $parameters; + public function __construct(int $value) { - parent::__construct( - "Invalid value $value: it must be a positive integer.", - 1632923676 - ); + $this->parameters = [ + 'value' => (string)$value, + ]; + + parent::__construct(StringFormatter::for($this), 1632923676); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidStringValue.php b/src/Type/Types/Exception/InvalidStringValue.php index 9765474..b921773 100644 --- a/src/Type/Types/Exception/InvalidStringValue.php +++ b/src/Type/Types/Exception/InvalidStringValue.php @@ -4,20 +4,35 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidStringValue extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match expected {expected_value}.'; + + /** @var array */ + private array $parameters; + public function __construct(string $value, string $expected) { - $value = ValueDumper::dump($value); - $expected = ValueDumper::dump($expected); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => ValueDumper::dump($expected), + ]; - parent::__construct( - "Value $value does not match expected $expected.", - 1631263740 - ); + parent::__construct(StringFormatter::for($this), 1631263740); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Type/Types/Exception/InvalidStringValueType.php b/src/Type/Types/Exception/InvalidStringValueType.php index c781217..fa2cf59 100644 --- a/src/Type/Types/Exception/InvalidStringValueType.php +++ b/src/Type/Types/Exception/InvalidStringValueType.php @@ -4,23 +4,38 @@ declare(strict_types=1); namespace CuyZ\Valinor\Type\Types\Exception; +use CuyZ\Valinor\Utility\String\StringFormatter; use CuyZ\Valinor\Utility\ValueDumper; use RuntimeException; /** @api */ final class InvalidStringValueType extends RuntimeException implements CastError { + private string $body = 'Value {value} does not match string value {expected_value}.'; + + /** @var array */ + private array $parameters; + /** * @param mixed $value */ - public function __construct($value, string $stringValue) + public function __construct($value, string $expected) { - $value = ValueDumper::dump($value); - $stringValue = ValueDumper::dump($stringValue); + $this->parameters = [ + 'value' => ValueDumper::dump($value), + 'expected_value' => ValueDumper::dump($expected), + ]; - parent::__construct( - "Value $value does not match string value $stringValue.", - 1631263954 - ); + parent::__construct(StringFormatter::for($this), 1631263954); + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; } } diff --git a/src/Utility/Polyfill.php b/src/Utility/Polyfill.php index c18516c..26a3af4 100644 --- a/src/Utility/Polyfill.php +++ b/src/Utility/Polyfill.php @@ -4,18 +4,6 @@ declare(strict_types=1); namespace CuyZ\Valinor\Utility; -use __PHP_Incomplete_Class; - -use function class_implements; -use function get_class; -use function get_parent_class; -use function is_array; -use function is_bool; -use function is_float; -use function is_int; -use function is_object; -use function is_string; -use function key; use function strlen; use function strncmp; use function strpos; @@ -62,51 +50,4 @@ final class Polyfill return $needleLength <= strlen($haystack) && 0 === substr_compare($haystack, $needle, -$needleLength); } - - /** - * @PHP8.0 use native function - * - * @param mixed $value - */ - public static function get_debug_type($value): string - { - switch (true) { - case null === $value: - return 'null'; - case is_bool($value): - return 'bool'; - case is_string($value): - return 'string'; - case is_array($value): - return 'array'; - case is_int($value): - return 'int'; - case is_float($value): - return 'float'; - case is_object($value): - break; - case $value instanceof __PHP_Incomplete_Class: // @phpstan-ignore-line - return '__PHP_Incomplete_Class'; - default: - // @phpstan-ignore-next-line - if (null === $type = @get_resource_type($value)) { - return 'unknown'; - } - - if ('Unknown' === $type) { - $type = 'closed'; - } - - return "resource ($type)"; - } - - $class = get_class($value); - - if (false === strpos($class, '@')) { - return $class; - } - - // @phpstan-ignore-next-line - return (get_parent_class($class) ?: key(class_implements($class)) ?: 'class') . '@anonymous'; - } } diff --git a/src/Utility/String/StringFormatter.php b/src/Utility/String/StringFormatter.php new file mode 100644 index 0000000..95126c7 --- /dev/null +++ b/src/Utility/String/StringFormatter.php @@ -0,0 +1,70 @@ + $parameters + */ + public static function format(string $locale, string $body, array $parameters = []): string + { + return class_exists(MessageFormatter::class) + ? self::formatWithIntl($locale, $body, $parameters) + : self::formatWithRegex($body, $parameters); + } + + public static function for(TranslatableMessage $message): string + { + return self::formatWithRegex($message->body(), $message->parameters()); + } + + /** + * @param array $parameters + */ + private static function formatWithIntl(string $locale, string $body, array $parameters): string + { + $message = MessageFormatter::formatMessage($locale, $body, $parameters); + + // @PHP8.0 use throw exception expression + if ($message === false) { + throw new StringFormatterError($body); + } + + return $message; + } + + /** + * @param array $parameters + */ + private static function formatWithRegex(string $body, array $parameters): string + { + $message = $body; + + if (preg_match('/{\s*[^}]*[^}a-z_]+\s*}?/', $body)) { + throw new StringFormatterError($body); + } + + foreach ($parameters as $name => $value) { + $name = preg_quote($name, '/'); + + /** @var string $message */ + $message = preg_replace("/{\s*$name\s*}/", $value, $message); + } + + return $message; + } +} diff --git a/src/Utility/String/StringFormatterError.php b/src/Utility/String/StringFormatterError.php new file mode 100644 index 0000000..418d986 --- /dev/null +++ b/src/Utility/String/StringFormatterError.php @@ -0,0 +1,16 @@ +types() as $subType) { + if ($type instanceof CompositeType) { + foreach ($type->traverse() as $subType) { if (self::containsObject($subType)) { return true; } } } - if ($type instanceof ShapedArrayType) { - foreach ($type->elements() as $element) { - if (self::containsObject($element->type())) { - return true; - } - } - } - return false; } } diff --git a/tests/Fake/Mapper/FakeNode.php b/tests/Fake/Mapper/FakeNode.php index 8f72055..f139828 100644 --- a/tests/Fake/Mapper/FakeNode.php +++ b/tests/Fake/Mapper/FakeNode.php @@ -7,7 +7,6 @@ namespace CuyZ\Valinor\Tests\Fake\Mapper; use CuyZ\Valinor\Definition\Attributes; use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Node; -use CuyZ\Valinor\Mapper\Tree\Shell; use CuyZ\Valinor\Tests\Fake\Definition\FakeAttributes; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage; use CuyZ\Valinor\Tests\Fake\Type\FakeType; @@ -26,7 +25,7 @@ final class FakeNode */ public static function leaf(Type $type, $value): Node { - $shell = Shell::root($type, $value); + $shell = FakeShell::new($type, $value); return Node::leaf($shell, $value); } @@ -37,7 +36,7 @@ final class FakeNode */ public static function branch(array $children, Type $type = null, $value = null): Node { - $shell = Shell::root($type ?? FakeType::permissive(), $value); + $shell = FakeShell::new($type ?? FakeType::permissive(), $value); $nodes = []; foreach ($children as $key => $child) { @@ -65,7 +64,7 @@ final class FakeNode */ public static function error(Throwable $error = null): Node { - $shell = Shell::root(FakeType::permissive(), []); + $shell = FakeShell::new(FakeType::permissive(), []); return Node::error($shell, $error ?? new FakeErrorMessage()); } diff --git a/tests/Fake/Mapper/FakeShell.php b/tests/Fake/Mapper/FakeShell.php new file mode 100644 index 0000000..7ed762c --- /dev/null +++ b/tests/Fake/Mapper/FakeShell.php @@ -0,0 +1,25 @@ +message; + parent::__construct($message, $code); } } diff --git a/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php b/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php new file mode 100644 index 0000000..69107d5 --- /dev/null +++ b/tests/Fake/Mapper/Tree/Message/FakeNodeMessage.php @@ -0,0 +1,16 @@ + */ + private array $parameters; + + /** + * @param array $parameters + */ + public function __construct(string $body = 'some message', array $parameters = []) + { + $this->body = $body; + $this->parameters = $parameters; + } + + public function body(): string + { + return $this->body; + } + + public function parameters(): array + { + return $this->parameters; + } + + public function code(): string + { + return '1652902453'; + } + + public function __toString() + { + return "$this->body (toString)"; + } +} diff --git a/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php b/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php new file mode 100644 index 0000000..ee47739 --- /dev/null +++ b/tests/Fake/Mapper/Tree/Message/Formatter/FakeMessageFormatter.php @@ -0,0 +1,25 @@ +body = $body; + } + } + + public function format(NodeMessage $message): NodeMessage + { + return $message->withBody($this->body ?? "formatted: {$message->body()}"); + } +} diff --git a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php index bc79265..2c9e170 100644 --- a/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php +++ b/tests/Integration/Mapping/ConstructorRegistrationMappingTest.php @@ -290,7 +290,7 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1642787246', $error->code()); - self::assertSame('Value array (empty) is not accepted.', (string)$error); + self::assertSame('Invalid value array (empty).', (string)$error); } } @@ -299,8 +299,8 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest try { $this->mapperBuilder ->registerConstructor( - fn (string $foo): stdClass => new stdClass(), fn (int $bar, float $baz = 1337.404): stdClass => new stdClass(), + fn (string $foo): stdClass => new stdClass(), ) ->mapper() ->map(stdClass::class, []); diff --git a/tests/Integration/Mapping/InterfaceInferringMappingTest.php b/tests/Integration/Mapping/InterfaceInferringMappingTest.php index 9125098..1c68eb3 100644 --- a/tests/Integration/Mapping/InterfaceInferringMappingTest.php +++ b/tests/Integration/Mapping/InterfaceInferringMappingTest.php @@ -158,7 +158,7 @@ final class InterfaceInferringMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1645283485', $error->code()); - self::assertSame('Invalid value 42, it must be an iterable.', (string)$error); + self::assertSame('Invalid value 42: it must be an array.', (string)$error); } } diff --git a/tests/Integration/Mapping/Message/MessageFormatterTest.php b/tests/Integration/Mapping/Message/MessageFormatterTest.php new file mode 100644 index 0000000..3253f8b --- /dev/null +++ b/tests/Integration/Mapping/Message/MessageFormatterTest.php @@ -0,0 +1,38 @@ +mapperBuilder->mapper()->map('int', 'foo'); + } catch (MappingError $error) { + $formatter = new AggregateMessageFormatter( + new LocaleMessageFormatter('fr'), + new MessageMapFormatter([ + 'Cannot cast {value} to {expected_type}.' => 'New message: {value} / {expected_type}', + ]), + (new TranslationMessageFormatter())->withTranslation( + 'fr', + 'New message: {value} / {expected_type}', + 'Nouveau message : {value} / {expected_type}', + ), + ); + + $message = $formatter->format($error->node()->messages()[0]); + + self::assertSame("Nouveau message : 'foo' / `int`", (string)$message); + } + } +} diff --git a/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php b/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php index 07631e6..02c18be 100644 --- a/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ArrayValuesMappingTest.php @@ -76,7 +76,7 @@ final class ArrayValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['nonEmptyArraysOfStrings']->messages()[0]; self::assertSame('1630678334', $error->code()); - self::assertSame('Value array (empty) does not match expected `non-empty-array`.', (string)$error); + self::assertSame('Value array (empty) does not match type `non-empty-array`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/DateTimeMappingTest.php b/tests/Integration/Mapping/Object/DateTimeMappingTest.php index 67f2219..f0508f2 100644 --- a/tests/Integration/Mapping/Object/DateTimeMappingTest.php +++ b/tests/Integration/Mapping/Object/DateTimeMappingTest.php @@ -76,7 +76,7 @@ final class DateTimeMappingTest extends IntegrationTest $error = $exception->node()->children()['dateTime']->messages()[0]; self::assertSame('1630686564', $error->code()); - self::assertSame("Impossible to parse date with value 'invalid datetime'.", (string)$error); + self::assertSame("Value 'invalid datetime' does not match a valid date format.", (string)$error); } } @@ -95,7 +95,7 @@ final class DateTimeMappingTest extends IntegrationTest $error = $exception->node()->children()['dateTime']->messages()[0]; self::assertSame('1630686564', $error->code()); - self::assertSame("Impossible to parse date with value '1337'.", (string)$error); + self::assertSame("Value 1337 does not match a valid date format.", (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ListValuesMappingTest.php b/tests/Integration/Mapping/Object/ListValuesMappingTest.php index 3e22b5d..d52560a 100644 --- a/tests/Integration/Mapping/Object/ListValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ListValuesMappingTest.php @@ -76,7 +76,7 @@ final class ListValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['nonEmptyListOfStrings']->messages()[0]; self::assertSame('1630678334', $error->code()); - self::assertSame('Value array (empty) does not match expected `non-empty-list`.', (string)$error); + self::assertSame('Value array (empty) does not match type `non-empty-list`.', (string)$error); } } diff --git a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php index a6083ac..3a2f02b 100644 --- a/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ObjectValuesMappingTest.php @@ -41,7 +41,7 @@ final class ObjectValuesMappingTest extends IntegrationTest $error = $exception->node()->messages()[0]; self::assertSame('1632903281', $error->code()); - self::assertSame("Value 'foo' does not match `array{object: ?, string: string}`.", (string)$error); + self::assertSame("Value 'foo' does not match type `array{object: ?, string: string}`.", (string)$error); } } } diff --git a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php index 44dbb23..bcd6f7c 100644 --- a/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php +++ b/tests/Integration/Mapping/Object/ScalarValuesMappingTest.php @@ -92,7 +92,7 @@ final class ScalarValuesMappingTest extends IntegrationTest $error = $exception->node()->children()['value']->messages()[0]; self::assertSame('1618736242', $error->code()); - self::assertSame('Cannot be empty and must be filled with a value of type `string`.', (string)$error); + self::assertSame('Cannot be empty and must be filled with a value matching type `string`.', (string)$error); } } } diff --git a/tests/Integration/Mapping/VisitorMappingTest.php b/tests/Integration/Mapping/VisitorMappingTest.php index 912286b..9b043f2 100644 --- a/tests/Integration/Mapping/VisitorMappingTest.php +++ b/tests/Integration/Mapping/VisitorMappingTest.php @@ -35,7 +35,7 @@ final class VisitorMappingTest extends IntegrationTest ->mapper() ->map(SimpleObject::class, ['value' => 'foo']); } catch (MappingError $exception) { - self::assertSame((string)$error, (string)$exception->node()->messages()[0]); + self::assertSame('some error message', (string)$exception->node()->messages()[0]); } self::assertSame(['#1', '#2'], $visits); diff --git a/tests/Unit/Mapper/Object/ArgumentsTest.php b/tests/Unit/Mapper/Object/ArgumentsTest.php index d7e57c4..54db9d4 100644 --- a/tests/Unit/Mapper/Object/ArgumentsTest.php +++ b/tests/Unit/Mapper/Object/ArgumentsTest.php @@ -39,6 +39,9 @@ final class ArgumentsTest extends TestCase Argument::optional('someOptionalArgument', $typeD, 'defaultValue') ); - self::assertSame("array{someArgument: $typeA, someArgumentOfObject: ?, someArgumentWithUnionOfObject: ?, someOptionalArgument?: $typeD}", $arguments->signature()); + self::assertSame( + "`array{someArgument: $typeA, someArgumentOfObject: ?, someArgumentWithUnionOfObject: ?, someOptionalArgument?: $typeD}`", + $arguments->signature() + ); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php index f89fc55..9022e96 100644 --- a/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ArrayNodeBuilderTest.php @@ -9,7 +9,7 @@ use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidTraversableKey; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ArrayKeyType; use CuyZ\Valinor\Type\Types\ArrayType; @@ -20,7 +20,7 @@ final class ArrayNodeBuilderTest extends TestCase { public function test_build_with_null_value_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(ArrayType::native(), null)); + $node = (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(ArrayType::native())); self::assertSame([], $node->value()); self::assertEmpty($node->children()); @@ -30,29 +30,29 @@ final class ArrayNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(new FakeType())); } public function test_build_with_invalid_source_throws_exception(): void { $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `array`."); + $this->expectExceptionMessage("Value 'foo' does not match type `array`."); - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root(ArrayType::native(), 'foo')); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new(ArrayType::native(), 'foo')); } public function test_invalid_source_key_throws_exception(): void { $this->expectException(InvalidTraversableKey::class); $this->expectExceptionCode(1630946163); - $this->expectExceptionMessage("Invalid key 'foo', it must be of type `int`."); + $this->expectExceptionMessage("Key 'foo' does not match type `int`."); $type = new ArrayType(ArrayKeyType::integer(), NativeStringType::get()); $value = [ 'foo' => 'key is not ok', ]; - (new RootNodeBuilder(new ArrayNodeBuilder()))->build(Shell::root($type, $value)); + (new RootNodeBuilder(new ArrayNodeBuilder()))->build(FakeShell::new($type, $value)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php index 7b29c9a..cd77787 100644 --- a/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/CasterNodeBuilderTest.php @@ -7,7 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use CuyZ\Valinor\Mapper\Tree\Builder\CasterNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\NoCasterForType; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -21,6 +21,6 @@ final class CasterNodeBuilderTest extends TestCase $this->expectExceptionCode(1630693475); $this->expectExceptionMessage("No caster was found to convert to type `$type`."); - (new RootNodeBuilder(new CasterNodeBuilder([])))->build(Shell::root($type, [])); + (new RootNodeBuilder(new CasterNodeBuilder([])))->build(FakeShell::new($type)); } } diff --git a/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php index 7a89ec4..14b7d2f 100644 --- a/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/EnumNodeBuilderTest.php @@ -8,8 +8,7 @@ use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\EnumNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\InvalidEnumValue; -use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fixture\Enum\BackedIntegerEnum; use CuyZ\Valinor\Tests\Fixture\Enum\BackedStringEnum; use CuyZ\Valinor\Tests\Fixture\Enum\PureEnum; @@ -35,7 +34,7 @@ final class EnumNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - $this->builder->build(Shell::root(new FakeType(), [])); + $this->builder->build(FakeShell::any()); } public function test_invalid_value_throws_exception(): void @@ -44,9 +43,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage("Invalid value 'foo', it must be one of 'FOO', 'BAR'."); + $this->expectExceptionMessage("Value 'foo' does not match any of 'FOO', 'BAR'."); - $this->builder->build(Shell::root($type, 'foo')); + $this->builder->build(FakeShell::new($type, 'foo')); } public function test_invalid_string_value_throws_exception(): void @@ -55,9 +54,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage("Invalid value object(stdClass), it must be one of 'foo', 'bar'."); + $this->expectExceptionMessage("Value object(stdClass) does not match any of 'foo', 'bar'."); - $this->builder->build(Shell::root($type, new stdClass())); + $this->builder->build(FakeShell::new($type, new stdClass())); } public function test_boolean_instead_of_integer_value_throws_exception(): void @@ -66,9 +65,9 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage('Invalid value false, it must be one of 42, 1337.'); + $this->expectExceptionMessage('Value false does not match any of 42, 1337.'); - $this->builder->build(Shell::root($type, false)); + $this->builder->build(FakeShell::new($type, false)); } public function test_invalid_integer_value_throws_exception(): void @@ -77,8 +76,8 @@ final class EnumNodeBuilderTest extends TestCase $this->expectException(InvalidEnumValue::class); $this->expectExceptionCode(1633093113); - $this->expectExceptionMessage('Invalid value object(stdClass), it must be one of 42, 1337.'); + $this->expectExceptionMessage('Value object(stdClass) does not match any of 42, 1337.'); - $this->builder->build(Shell::root($type, new stdClass())); + $this->builder->build(FakeShell::new($type, new stdClass())); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php index 1618d72..38c2225 100644 --- a/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ListNodeBuilderTest.php @@ -8,7 +8,7 @@ use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\ListNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ListType; use PHPUnit\Framework\TestCase; @@ -17,7 +17,7 @@ final class ListNodeBuilderTest extends TestCase { public function test_build_with_null_value_returns_empty_branch_node(): void { - $node = (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(ListType::native(), null)); + $node = (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(ListType::native())); self::assertSame([], $node->value()); self::assertEmpty($node->children()); @@ -27,15 +27,15 @@ final class ListNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(new FakeType())); } public function test_build_with_invalid_source_throws_exception(): void { $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `list`."); + $this->expectExceptionMessage("Value 'foo' does not match type `list`."); - (new RootNodeBuilder(new ListNodeBuilder()))->build(Shell::root(ListType::native(), 'foo')); + (new RootNodeBuilder(new ListNodeBuilder()))->build(FakeShell::new(ListType::native(), 'foo')); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php index 6fb318b..e1785ff 100644 --- a/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ScalarNodeBuilderTest.php @@ -7,8 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Builder; use AssertionError; use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ScalarNodeBuilder; -use CuyZ\Valinor\Mapper\Tree\Shell; -use CuyZ\Valinor\Tests\Fake\Type\FakeType; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use PHPUnit\Framework\TestCase; final class ScalarNodeBuilderTest extends TestCase @@ -17,6 +16,6 @@ final class ScalarNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ScalarNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ScalarNodeBuilder()))->build(FakeShell::any()); } } diff --git a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php index 701a14e..1da2877 100644 --- a/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php +++ b/tests/Unit/Mapper/Tree/Builder/ShapedArrayNodeBuilderTest.php @@ -9,7 +9,7 @@ use CuyZ\Valinor\Mapper\Tree\Builder\RootNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Builder\ShapedArrayNodeBuilder; use CuyZ\Valinor\Mapper\Tree\Exception\ShapedArrayElementMissing; use CuyZ\Valinor\Mapper\Tree\Exception\SourceMustBeIterable; -use CuyZ\Valinor\Mapper\Tree\Shell; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Type\FakeObjectType; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use CuyZ\Valinor\Type\Types\ShapedArrayElement; @@ -23,7 +23,7 @@ final class ShapedArrayNodeBuilderTest extends TestCase { $this->expectException(AssertionError::class); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root(new FakeType(), [])); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::any()); } public function test_build_with_invalid_source_throws_exception(): void @@ -32,9 +32,9 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' does not match expected `array{foo: SomeType}`."); + $this->expectExceptionMessage("Value 'foo' does not match type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, 'foo')); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, 'foo')); } public function test_build_with_invalid_source_for_shaped_array_containing_object_type_throws_exception(): void @@ -43,9 +43,9 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Value 'foo' is not accepted."); + $this->expectExceptionMessage("Invalid value 'foo'."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, 'foo')); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, 'foo')); } public function test_build_with_null_source_throws_exception(): void @@ -54,19 +54,19 @@ final class ShapedArrayNodeBuilderTest extends TestCase $this->expectException(SourceMustBeIterable::class); $this->expectExceptionCode(1618739163); - $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching `array{foo: SomeType}`."); + $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `array{foo: SomeType}`."); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, null)); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type)); } public function test_build_with_missing_key_throws_exception(): void { $this->expectException(ShapedArrayElementMissing::class); $this->expectExceptionCode(1631613641); - $this->expectExceptionMessage("Missing element `foo` of type `SomeType`."); + $this->expectExceptionMessage("Missing element `foo` matching type `SomeType`."); $type = new ShapedArrayType(new ShapedArrayElement(new StringValueType('foo'), new FakeType('SomeType'))); - (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(Shell::root($type, [])); + (new RootNodeBuilder(new ShapedArrayNodeBuilder()))->build(FakeShell::new($type, [])); } } diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php new file mode 100644 index 0000000..b5ff3ec --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/AggregateMessageFormatterTest.php @@ -0,0 +1,25 @@ +format(FakeNodeMessage::any()); + + self::assertSame('message B', (string)$message); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php new file mode 100644 index 0000000..937dc85 --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/LocaleMessageFormatterTest.php @@ -0,0 +1,20 @@ +format($message); + + self::assertSame('fr', $message->locale()); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php index cf09842..ea129b5 100644 --- a/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php +++ b/tests/Unit/Mapper/Tree/Message/Formatter/MessageMapFormatterTest.php @@ -6,65 +6,76 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message\Formatter; use CuyZ\Valinor\Mapper\Tree\Message\Formatter\MessageMapFormatter; use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; -use CuyZ\Valinor\Tests\Fake\Mapper\FakeNode; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage; use PHPUnit\Framework\TestCase; final class MessageMapFormatterTest extends TestCase { public function test_format_finds_code_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some_code' => 'foo', - ]); + $formatter = (new MessageMapFormatter([ + 'some_code' => 'ok', + 'some message' => 'nope', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok', (string)$message); } public function test_format_finds_code_returns_formatted_content_from_callback(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some_code' => fn (NodeMessage $message) => "foo $message", - ]); + $formatter = (new MessageMapFormatter([ + 'some_code' => fn (NodeMessage $message) => "ok $message", + 'some message' => 'nope', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo some message', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok some message', (string)$message); } - public function test_format_finds_content_returns_formatted_content(): void + public function test_format_finds_body_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ - 'some message' => 'foo', - ]); + $formatter = (new MessageMapFormatter([ + 'some message' => 'ok', + FakeMessage::class => 'nope', + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('ok', (string)$message); } public function test_format_finds_class_name_returns_formatted_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); - $formatter = new MessageMapFormatter([ + $formatter = (new MessageMapFormatter([ FakeMessage::class => 'foo', - ]); + ]))->defaultsTo('nope'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('foo', (string)$message); } public function test_format_does_not_find_any_returns_default(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); $formatter = (new MessageMapFormatter([]))->defaultsTo('foo'); - self::assertSame('foo', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('foo', (string)$message); } public function test_format_does_not_find_any_returns_message_content(): void { - $message = new NodeMessage(FakeNode::any(), new FakeMessage()); $formatter = new MessageMapFormatter([]); - self::assertSame('some message', $formatter->format($message)); + $message = $formatter->format(FakeNodeMessage::any()); + + self::assertSame('some message', (string)$message); } } diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php new file mode 100644 index 0000000..bb0444b --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/PlaceHolderMessageFormatterTest.php @@ -0,0 +1,48 @@ +child('foo', $type, 'some value'); + + $message = new NodeMessage($shell, new FakeMessage('some message')); + $message = (new FakeMessageFormatter('%1$s / %2$s / %3$s / %4$s / %5$s'))->format($message); + $message = (new PlaceHolderMessageFormatter())->format($message); + + self::assertSame("some_code / some message / `$type` / foo / foo", (string)$message); + } + + public function test_format_message_replaces_correct_original_value_if_throwable(): void + { + $message = new NodeMessage(FakeShell::any(), new FakeErrorMessage('some error message')); + $message = (new FakeMessageFormatter('original: %2$s'))->format($message); + $message = (new PlaceHolderMessageFormatter())->format($message); + + self::assertSame('original: some error message', (string)$message); + } + + public function test_format_message_replaces_placeholders_with_given_values(): void + { + $formatter = new PlaceHolderMessageFormatter('foo', 'bar'); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('%1$s / %2$s')); + $message = $formatter->format($message); + + self::assertSame('foo / bar', (string)$message); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php b/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php new file mode 100644 index 0000000..9f06eae --- /dev/null +++ b/tests/Unit/Mapper/Tree/Message/Formatter/TranslationMessageFormatterTest.php @@ -0,0 +1,132 @@ +withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some message', (string)$message); + } + + public function test_format_message_with_added_translation_formats_message_correctly(): void + { + $formatter = (new TranslationMessageFormatter())->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ])->withTranslation( + 'en', + 'some key', + 'some other message' + ); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_overridden_translations_formats_message_correctly(): void + { + $formatter = (new TranslationMessageFormatter()) + ->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + ])->withTranslations([ + 'some key' => [ + 'en' => 'some other message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_overridden_translations_keeps_other_translations(): void + { + $formatter = (new TranslationMessageFormatter()) + ->withTranslations([ + 'some key' => [ + 'en' => 'some message', + ], + 'some other key' => [ + 'en' => 'some other message', + ], + ])->withTranslations([ + 'some key' => [ + 'en' => 'some new message', + ], + ]); + + $message = new NodeMessage(FakeShell::any(), new FakeMessage('some other key')); + $message = $formatter->format($message); + + self::assertSame('some other message', (string)$message); + } + + public function test_format_message_with_default_translations_formats_message_correctly(): void + { + $formatter = TranslationMessageFormatter::default()->withTranslation( + 'en', + 'Value {value} is not accepted.', + 'Value {value} is not accepted!' + ); + + $originalMessage = new FakeTranslatableMessage('Value {value} is not accepted.', ['value' => 'foo']); + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $message = $formatter->format($message); + + self::assertSame('Value foo is not accepted!', (string)$message); + } + + public function test_format_message_with_unknown_translation_returns_same_instance(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = (new TranslationMessageFormatter())->format($messageA); + + self::assertSame($messageA, $messageB); + } + + public function test_with_translation_returns_clone(): void + { + $formatterA = new TranslationMessageFormatter(); + $formatterB = $formatterA->withTranslation('en', 'some message', 'some other message'); + + self::assertNotSame($formatterA, $formatterB); + } + + public function test_with_translations_returns_clone(): void + { + $formatterA = new TranslationMessageFormatter(); + $formatterB = $formatterA->withTranslations([ + 'some message' => [ + 'en' => 'some other message', + ], + ]); + + self::assertNotSame($formatterA, $formatterB); + } +} diff --git a/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php b/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php index 904210c..a4ea6ae 100644 --- a/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php +++ b/tests/Unit/Mapper/Tree/Message/NodeMessageTest.php @@ -4,11 +4,15 @@ declare(strict_types=1); namespace CuyZ\Valinor\Tests\Unit\Mapper\Tree\Message; +use CuyZ\Valinor\Mapper\Tree\Message\Message; use CuyZ\Valinor\Mapper\Tree\Message\NodeMessage; use CuyZ\Valinor\Tests\Fake\Definition\FakeAttributes; -use CuyZ\Valinor\Tests\Fake\Mapper\FakeNode; +use CuyZ\Valinor\Tests\Fake\Mapper\FakeShell; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage; use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeNodeMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeTranslatableMessage; +use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\Formatter\FakeMessageFormatter; use CuyZ\Valinor\Tests\Fake\Type\FakeType; use PHPUnit\Framework\TestCase; @@ -20,15 +24,8 @@ final class NodeMessageTest extends TestCase $type = FakeType::permissive(); $attributes = new FakeAttributes(); - $node = FakeNode::branch([[ - 'name' => 'foo', - 'type' => $type, - 'value' => 'some value', - 'attributes' => $attributes, - 'message' => $originalMessage - ]])->children()['foo']; - - $message = new NodeMessage($node, $originalMessage); + $shell = FakeShell::any()->child('foo', $type, 'some value', $attributes); + $message = new NodeMessage($shell, $originalMessage); self::assertSame('foo', $message->name()); self::assertSame('foo', $message->path()); @@ -38,42 +35,84 @@ final class NodeMessageTest extends TestCase self::assertSame($originalMessage, $message->originalMessage()); } - public function test_value_from_invalid_node_returns_null(): void + public function test_message_is_error_if_original_message_is_throwable(): void { $originalMessage = new FakeErrorMessage(); - $node = FakeNode::leaf(FakeType::permissive(), 'foo')->withMessage($originalMessage); + $message = new NodeMessage(FakeShell::any(), $originalMessage); - $message = new NodeMessage($node, $originalMessage); - - self::assertNull($message->value()); + self::assertTrue($message->isError()); + self::assertSame('1652883436', $message->code()); + self::assertSame('some error message', $message->body()); } - public function test_format_message_replaces_placeholders_with_default_values(): void + public function test_parameters_are_replaced_in_body(): void { - $originalMessage = new FakeMessage(); + $originalMessage = new FakeTranslatableMessage('some original message', ['some_parameter' => 'some parameter value']); $type = FakeType::permissive(); + $shell = FakeShell::any()->child('foo', $type, 'some value'); - $node = FakeNode::branch([[ - 'name' => 'foo', - 'type' => $type, - 'value' => 'some value', - 'message' => $originalMessage - ]])->children()['foo']; + $message = new NodeMessage($shell, $originalMessage); + $message = $message->withBody('{message_code} / {node_name} / {node_path} / {node_type} / {original_value} / {original_message} / {some_parameter}'); - $message = new NodeMessage($node, $originalMessage); - $text = $message->format('%1$s / %2$s / %3$s / %4$s / %5$s'); - - self::assertSame("some_code / some message / $type / foo / foo", $text); + self::assertSame("1652902453 / foo / foo / `$type` / 'some value' / some original message (toString) / some parameter value", (string)$message); } - public function test_format_message_replaces_placeholders_with_given_values(): void + public function test_replaces_correct_original_message_if_throwable(): void { - $originalMessage = new FakeMessage(); - $node = FakeNode::any(); + $message = new NodeMessage(FakeShell::any(), new FakeErrorMessage('some error message')); + $message = $message->withBody('original: {original_message}'); - $message = new NodeMessage($node, $originalMessage); - $text = $message->format('%1$s / %2$s', 'foo', 'bar'); + self::assertSame('original: some error message', (string)$message); + } - self::assertSame('foo / bar', $text); + public function test_format_message_uses_formatter_to_replace_content(): void + { + $originalMessage = new FakeMessage('some message'); + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $formattedMessage = (new FakeMessageFormatter())->format($message); + + self::assertNotSame($message, $formattedMessage); + self::assertSame('formatted: some message', (string)$formattedMessage); + } + + public function test_custom_body_returns_clone(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = $messageA->withBody('some other message'); + + self::assertNotSame($messageA, $messageB); + } + + public function test_custom_locale_returns_clone(): void + { + $messageA = FakeNodeMessage::any(); + $messageB = $messageA->withLocale('fr'); + + self::assertNotSame($messageA, $messageB); + } + + public function test_custom_locale_is_used(): void + { + $originalMessage = new FakeTranslatableMessage('un message: {value, spellout}', ['value' => '42']); + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + $message = $message->withLocale('fr'); + + self::assertSame('un message: quarante-deux', (string)$message); + } + + public function test_message_with_no_code_returns_unknown(): void + { + $originalMessage = new class () implements Message { + public function __toString(): string + { + return 'some message'; + } + }; + + $message = new NodeMessage(FakeShell::any(), $originalMessage); + + self::assertSame('unknown', $message->code()); } } diff --git a/tests/Unit/Mapper/Tree/NodeTest.php b/tests/Unit/Mapper/Tree/NodeTest.php index bf46b2f..be3d20a 100644 --- a/tests/Unit/Mapper/Tree/NodeTest.php +++ b/tests/Unit/Mapper/Tree/NodeTest.php @@ -37,7 +37,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 'foo' does not match expected `$type`."); + $this->expectExceptionMessage("Value 'foo' does not match type `$type`."); FakeNode::leaf($type, 'foo'); } @@ -82,7 +82,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 'foo' does not match expected `$type`."); + $this->expectExceptionMessage("Value 'foo' does not match type `$type`."); FakeNode::branch([], $type, 'foo'); } @@ -93,7 +93,7 @@ final class NodeTest extends TestCase $node = FakeNode::error($message); self::assertFalse($node->isValid()); - self::assertSame((string)$message, (string)$node->messages()[0]); + self::assertSame('some error message', (string)$node->messages()[0]); } public function test_get_value_from_invalid_node_throws_exception(): void @@ -133,7 +133,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 1337 does not match expected `$type`."); + $this->expectExceptionMessage("Value 1337 does not match type `$type`."); FakeNode::leaf($type, 'foo')->withValue(1337); } @@ -145,7 +145,7 @@ final class NodeTest extends TestCase $this->expectException(InvalidNodeValue::class); $this->expectExceptionCode(1630678334); - $this->expectExceptionMessage("Value 1337 is not accepted."); + $this->expectExceptionMessage('Invalid value 1337.'); FakeNode::leaf($type, $object)->withValue(1337); } @@ -174,7 +174,7 @@ final class NodeTest extends TestCase self::assertNotSame($nodeA, $nodeB); self::assertFalse($nodeB->isValid()); - self::assertSame((string)$message, (string)$nodeB->messages()[0]); - self::assertSame((string)$errorMessage, (string)$nodeB->messages()[1]); + self::assertSame('some message', (string)$nodeB->messages()[0]); + self::assertSame('some error message', (string)$nodeB->messages()[1]); } } diff --git a/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php b/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php index bddd66a..9e08c6a 100644 --- a/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php +++ b/tests/Unit/Type/Resolver/Union/UnionNullNarrowerTest.php @@ -54,7 +54,7 @@ final class UnionNullNarrowerTest extends TestCase $this->expectException(UnionTypeDoesNotAllowNull::class); $this->expectExceptionCode(1618742357); - $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching `$unionType`."); + $this->expectExceptionMessage("Cannot be empty and must be filled with a value matching type `$unionType`."); $this->unionNullNarrower->narrow($unionType, null); } diff --git a/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php b/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php index 95cecb4..aab3feb 100644 --- a/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php +++ b/tests/Unit/Type/Resolver/Union/UnionScalarNarrowerTest.php @@ -112,7 +112,7 @@ final class UnionScalarNarrowerTest extends TestCase $this->expectException(CannotResolveTypeFromUnion::class); $this->expectExceptionCode(1607027306); - $this->expectExceptionMessage("Value 'foo' is not accepted."); + $this->expectExceptionMessage("Invalid value 'foo'."); $this->unionScalarNarrower->narrow($unionType, 'foo'); } diff --git a/tests/Unit/Utility/String/StringFormatterTest.php b/tests/Unit/Utility/String/StringFormatterTest.php new file mode 100644 index 0000000..39ed8eb --- /dev/null +++ b/tests/Unit/Utility/String/StringFormatterTest.php @@ -0,0 +1,44 @@ +expectException(StringFormatterError::class); + $this->expectExceptionMessage('Message formatter error using `some {wrong.format}`.'); + $this->expectExceptionCode(1652901203); + + StringFormatter::format('en', 'some {wrong.format}', []); + } + + public function test_wrong_message_body_format_throws_exception(): void + { + $this->expectException(StringFormatterError::class); + $this->expectExceptionMessage('Message formatter error using `some message with {invalid format}`.'); + $this->expectExceptionCode(1652901203); + + StringFormatter::for(new FakeTranslatableMessage('some message with {invalid format}')); + } + + public function test_parameters_are_replaced_correctly(): void + { + $message = new FakeTranslatableMessage('some message with {valid_parameter}', [ + 'invalid )( parameter' => 'invalid value', + 'valid_parameter' => 'valid value', + ]); + + self::assertSame('some message with valid value', StringFormatter::for($message)); + } +}