diff --git a/AGENTS.md b/AGENTS.md index 469fd49..8cff65b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,27 @@ When creating new formatters: All formatters must implement the `Respect\StringFormatter\Formatter` interface. +## Modifier Development + +When creating new modifiers: + +1. **Follow Chain of Responsibility pattern**: Check pipe value and delegate to next modifier +2. **Use template structure**: Similar to `src/Modifier/QuoteModifier.php` +3. **Test with TestingModifier**: Located in `tests/Helper/TestingModifier.php` +4. **Handle type checking**: Always check input types before processing +5. **Return string values**: Modifiers must return strings +6. **Use Stringifier Quoter**: For string operations, inject `\Respect\Stringifier\Quoter` with CodeQuoter as default + +All modifiers must implement the `Respect\StringFormatter\Modifier` interface. + +## Testing Guidelines + +1. **Avoid PHPUnit mocks**: Create custom test implementations instead of using createMock() +2. **Use custom test quoter**: Follow pattern in `tests/Helper/TestingQuoter.php` +3. **Test contracts not implementations**: Verify interactions without depending on specific behavior +4. **Make test properties public**: When using anonymous classes to access test state +5. **Verify method calls**: Track whether methods were called and with what parameters + ## Commit Guidelines Follow the detailed rules in `docs/contributing/commit-guidelines.md`: diff --git a/composer.json b/composer.json index 1fb8a12..dd29454 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,11 @@ "require": { "symfony/polyfill-mbstring": "^1.33", "php": "^8.5", - "respect/stringifier": "^3.0" + "respect/stringifier": "^3.0", + "symfony/translation-contracts": "^3.6" + }, + "suggest": { + "symfony/translation": "For translation support in TransModifier (^6.0|^7.0)" }, "require-dev": { "phpunit/phpunit": "^12.5", @@ -13,7 +17,8 @@ "phpstan/extension-installer": "^1.4", "phpstan/phpstan-deprecation-rules": "^2.0", "phpstan/phpstan-phpunit": "^2.0", - "respect/coding-standard": "^5.0" + "respect/coding-standard": "^5.0", + "symfony/translation": "^6.0|^7.0" }, "license": "ISC", "autoload": { diff --git a/docs/PlaceholderFormatter.md b/docs/PlaceholderFormatter.md index 8e5ae23..b698ba6 100644 --- a/docs/PlaceholderFormatter.md +++ b/docs/PlaceholderFormatter.md @@ -1,380 +1,95 @@ # PlaceholderFormatter -The `PlaceholderFormatter` replaces `{{placeholder}}` markers in strings with values from a parameters array. All non-string values are converted to strings using a `Stringifier` instance. +The `PlaceholderFormatter` replaces `{{placeholder}}` markers in strings with values from a parameters array. ## Usage -### Basic Usage - ```php use Respect\StringFormatter\PlaceholderFormatter; -use Respect\Stringifier\HandlerStringifier; $formatter = new PlaceholderFormatter(['name' => 'John', 'age' => 30]); echo $formatter->format('Hello {{name}}, you are {{age}} years old.'); -// Outputs: "Hello John, you are 30 years old." -``` - -### With Custom Stringifier - -```php -use Respect\StringFormatter\PlaceholderFormatter; -use Respect\Stringifier\HandlerStringifier; - -$stringifier = HandlerStringifier::create(); - -$formatter = new PlaceholderFormatter( - ['name' => 'John', 'items' => ['a', 'b', 'c']], - $stringifier -); - -echo $formatter->format('Hello {{name}}, items: {{items}}'); -// Outputs: "Hello John, items: [a, b, c]" +// Outputs: Hello John, you are 30 years old. ``` ### Using Additional Parameters -The `formatWith` method allows passing additional parameters at format time. Constructor parameters take precedence and won't be overwritten. +The `formatUsing` method allows passing additional parameters at format time. Constructor parameters take precedence and won't be overwritten. ```php -use Respect\StringFormatter\PlaceholderFormatter; - -// Create formatter with base parameters $formatter = new PlaceholderFormatter(['siteName' => 'MyApp', 'year' => 2024]); -// Add additional parameters at format time -echo $formatter->formatWith( +echo $formatter->formatUsing( 'Welcome to {{siteName}} - Hello {{userName}}! © {{year}}', ['userName' => 'John', 'year' => 2020] // year won't override constructor value ); -// Outputs: "Welcome to MyApp - Hello John! © 2024" +// Outputs: Welcome to MyApp - Hello John! © 2024 ``` -## API - -### `PlaceholderFormatter::__construct` +### With Modifiers -- `__construct(array $parameters, Stringifier|null $stringifier = null)` +Placeholders can include modifiers that transform values. See the [Modifiers](modifiers/Modifiers.md) documentation for details. -Creates a new formatter instance with the specified parameters and stringifier. - -**Parameters:** - -- `$parameters`: Associative array of placeholder names to values -- `$stringifier`: Stringifier instance for converting all non-string values to strings. If `null`, it creates its own stringifier. - -### `format` - -- `format(string $input): string` - -Formats the template string by replacing `{{placeholder}}` syntax with corresponding parameter values. - -**Parameters:** - -- `$input`: The template string containing placeholders +```php +$formatter = new PlaceholderFormatter(['name' => 'John']); -**Returns:** The formatted string with placeholders replaced by their values +echo $formatter->format('Hello {{name|upper}}!'); +// Outputs: Hello JOHN! +``` -### `formatWith` +## API -- `formatWith(string $input, array $parameters): string` +### `__construct(array $parameters, Modifier|null $modifier = null)` -Formats the template string with additional parameters merged with constructor parameters. Constructor parameters take precedence and won't be overwritten by additional parameters. +Creates a new formatter instance. -**Parameters:** +- `$parameters`: Associative array of placeholder names to values +- `$modifier`: Optional modifier chain. If `null`, uses default modifiers. -- `$input`: The template string containing placeholders -- `$parameters`: Additional associative array of placeholder names to values +### `format(string $input): string` -**Returns:** The formatted string with placeholders replaced by their values +Formats the template string by replacing placeholders with parameter values. -**Behavior:** +### `formatUsing(string $input, array $parameters): string` -- Additional parameters are merged with constructor parameters -- Constructor parameters always take precedence (cannot be overwritten) -- Useful for adding context-specific values while keeping base values consistent +Formats with additional parameters merged with constructor parameters. Constructor parameters take precedence. ## Template Syntax -### Placeholder Format - -Placeholders follow the format `{{name}}` where `name` is a valid parameter key. +Placeholders follow the format `{{name}}` where `name` is a valid parameter key. Modifiers can be added after a pipe: `{{name|modifier}}`. **Rules:** -- Placeholder names must match the regex pattern `\w+` (word characters: letters, digits, underscore) -- Names are case-sensitive: `{{Name}}` and `{{name}}` are different placeholders -- Placeholders can appear multiple times in the template -- Whitespace inside braces is not allowed: `{{ name }}` will not match - -**Valid placeholders:** +- Names must match `\w+` (letters, digits, underscore) +- Names are case-sensitive +- No whitespace inside braces or around the pipe -- `{{name}}` -- `{{firstName}}` -- `{{value123}}` -- `{{user_id}}` +**Valid:** `{{name}}`, `{{user_id}}`, `{{name|raw}}` -**Invalid placeholders (treated as literals):** - -- `{name}` (single braces) -- `{{ name }}` (contains spaces) -- `{{first-name}}` (contains hyphen) -- `{{}}` (empty) - -## Type Handling - -The formatter uses the injected `Stringifier` to convert all parameter values to strings: - -| Type | Behavior | Example | -| ---------- | -------------------------------------------------------------- | ----------------------------------------------------- | -| `string` | Used as-is | `"hello"` → `"hello"` | -| `int` | Converted using stringifier | `42` → `"42"` | -| `float` | Converted using stringifier | `19.99` → `"19.99"` | -| `bool` | Converted using stringifier with backticks | `true` → `` `true` ``, `false` → `` `false` `` | -| `null` | Converted using stringifier with backticks | `null` → `` `null` `` | -| `array` | Converted using stringifier (or `print_r` as fallback) | `[1, 2]` → `"[1, 2]"` (varies) | -| `object` | Converted using stringifier (or `print_r` as fallback) | Varies by object type | -| Stringable | Converted using stringifier (includes metadata with backticks) | `__toString()` → `` `Stringable@anonymous { ... }` `` | -| Resource | Converted using stringifier (or `print_r` as fallback) | Resource representation | -| Missing | Keeps placeholder unchanged (parameter key doesn't exist) | N/A → `"{{name}}"` | +**Invalid:** `{name}`, `{{ name }}`, `{{first-name}}`, `{{}}` ## Behavior -### Successful Replacement - -When a placeholder name exists as a parameter key: - -- The placeholder is replaced with the stringified value (using the injected `Stringifier`) -- String values are used as-is without stringification -- All non-string values (including `null`) are converted using the stringifier -- Empty strings are valid replacements: `""` replaces the placeholder with nothing -- Zero values are valid: `0` and `0.0` are replaced with their string representations - -### Placeholder Preservation - -Placeholders are kept unchanged (as literal text) when: - -- The parameter key doesn't exist in the parameters array -- The placeholder syntax is malformed (e.g., single braces, spaces inside braces) - -### Null Value Handling - -**Important:** Unlike some template engines, `null` values are **converted to the string `` `null` ``** (with backticks) rather than preserving the placeholder or using an empty string. This ensures explicit representation of null values in the output. - -### Edge Cases - -- **Empty template**: Returns empty string -- **No placeholders**: Returns template unchanged -- **Empty parameters**: All placeholders remain unchanged -- **Repeated placeholders**: Each occurrence is replaced independently with the same value -- **Unicode support**: Full support for Unicode in template text, placeholder names, and values +- **Missing parameters**: Placeholders are kept unchanged +- **Null values**: Converted to `` `null` `` string representation +- **Empty strings**: Valid replacements (placeholder becomes empty) +- **Repeated placeholders**: Each occurrence replaced independently +- **Unicode**: Fully supported in templates and values ## Examples -### Basic Examples - -| Parameters | Template | Output | -| ---------------------- | --------------------------- | --------------------- | -| `['name' => 'John']` | `"Hello {{name}}!"` | `"Hello John!"` | -| `['x' => 1, 'y' => 2]` | `"{{x}} + {{y}} = 3"` | `"1 + 2 = 3"` | -| `['name' => 'Alice']` | `"{{name}} loves {{name}}"` | `"Alice loves Alice"` | -| `['value' => '']` | `"Value: [{{value}}]"` | `"Value: []"` | -| `['count' => 0]` | `"Count: {{count}}"` | `"Count: 0"` | - -### Missing and Null Values - -| Parameters | Template | Output | -| -------------------------- | --------------------------------- | ----------------------------- | -| `['name' => 'John']` | `"{{name}} is {{age}} years old"` | `"John is {{age}} years old"` | -| `['name' => null]` | `"Hello {{name}}"` | ``"Hello `null`"`` | -| `[]` | `"Hello {{name}}"` | `"Hello {{name}}"` | -| `['a' => 'A', 'c' => 'C']` | `"{{a}}-{{b}}-{{c}}"` | `"A-{{b}}-C"` | - -### Type Conversions - -| Parameters | Template | Output | -| --------------------- | ---------------------- | --------------------- | -| `['count' => 42]` | `"Count: {{count}}"` | `"Count: 42"` | -| `['price' => 19.99]` | `"Price: ${{price}}"` | `"Price: $19.99"` | -| `['active' => true]` | `"Active: {{active}}"` | ``"Active: `true`"`` | -| `['active' => false]` | `"Active: {{active}}"` | ``"Active: `false`"`` | - -### formatWith Examples - -| Constructor Params | Additional Params | Template | Output | -| -------------------- | ---------------------- | ------------------------ | ------------------------- | -| `['name' => 'John']` | `['age' => 30]` | `"{{name}} is {{age}}"` | `"John is 30"` | -| `['name' => 'John']` | `['name' => 'Jane']` | `"Hello {{name}}"` | `"Hello John"` (not Jane) | -| `['app' => 'MyApp']` | `['user' => 'Bob']` | `"{{app}}: Hi {{user}}"` | `"MyApp: Hi Bob"` | -| `[]` | `['x' => 1, 'y' => 2]` | `"{{x}} + {{y}}"` | `"1 + 2"` | - -### Malformed Placeholders - -| Parameters | Template | Output | -| -------------------- | ------------------------ | ------------------------ | -| `['name' => 'John']` | `"Hello {name}"` | `"Hello {name}"` | -| `['name' => 'John']` | `"Hello {{ name }}"` | `"Hello {{ name }}"` | -| `['name' => 'John']` | `"Hello {{{name}}}"` | `"Hello {John}"` | -| `['name' => 'John']` | `"Hello {{}}"` | `"Hello {{}}"` | -| `['name' => 'John']` | `"Hello {{first-name}}"` | `"Hello {{first-name}}"` | - -### Unicode Support - -| Parameters | Template | Output | -| -------------------------- | ---------------------- | ---------------- | -| `['name' => 'José']` | `"Hola {{name}}!"` | `"Hola José!"` | -| `['greeting' => 'Привет']` | `"{{greeting}} World"` | `"Привет World"` | -| `['emoji' => '🎉']` | `"Party {{emoji}}"` | `"Party 🎉"` | -| `['text' => '你好']` | `"{{text}},世界"` | `"你好,世界"` | - -## Use Cases - -### Email Templates - -```php - -$formatter = new PlaceholderFormatter([ - 'customerName' => 'Bob Smith', - 'orderNumber' => 'ORD-2024-001', - 'total' => 149.99 -]); - -$email = $formatter->format(<<<'EMAIL' -Dear {{customerName}}, - -Thank you for your order {{orderNumber}}. -Total amount: ${{total}} - -We will process your order shortly. -EMAIL); -``` - -### Log Messages - -```php - -$formatter = new PlaceholderFormatter([ - 'user' => 'admin', - 'action' => 'login', - 'ip' => '192.168.1.100', - 'timestamp' => '2024-01-18 10:30:45' -]); - -echo $formatter->format('[{{timestamp}}] User {{user}} performed {{action}} from {{ip}}'); -// Outputs: "[2024-01-18 10:30:45] User admin performed login from 192.168.1.100" -``` - -### Notification Messages - -```php - -$formatter = new PlaceholderFormatter([ - 'count' => 3, - 'type' => 'comments', - 'post' => 'Introduction to PHP' -]); - -echo $formatter->format('You have {{count}} new {{type}} on "{{post}}"'); -// Outputs: "You have 3 new comments on "Introduction to PHP"" -``` - -### URL Generation - -```php - -$formatter = new PlaceholderFormatter([ - 'scheme' => 'https', - 'domain' => 'api.example.com', - 'version' => 'v1', - 'resource' => 'users', - 'id' => 12345 -]); - -echo $formatter->format('{{scheme}}://{{domain}}/{{version}}/{{resource}}/{{id}}'); -// Outputs: "https://api.example.com/v1/users/12345" -``` - -### Dynamic Content - -```php - -$formatter = new PlaceholderFormatter([ - 'siteName' => 'MyApp', - 'year' => 2024, - 'version' => '2.1.0' -]); - -echo $formatter->format('Welcome to {{siteName}} v{{version}} - © {{year}}'); -// Outputs: "Welcome to MyApp v2.1.0 - © 2024" -``` - -### Reusable Templates with Context - -Using `formatWith` to create reusable formatters with context-specific values: - -```php -use Respect\StringFormatter\PlaceholderFormatter; - -// Create a reusable formatter with common parameters -$emailFormatter = new PlaceholderFormatter([ - 'companyName' => 'Acme Corp', - 'supportEmail' => 'support@acme.com', - 'year' => 2024 -]); - -// Use with different recipients -$email1 = $emailFormatter->formatWith( - 'Dear {{customerName}}, thank you for contacting {{companyName}}. Reply to {{supportEmail}}.', - ['customerName' => 'Alice'] -); -// Outputs: "Dear Alice, thank you for contacting Acme Corp. Reply to support@acme.com." - -$email2 = $emailFormatter->formatWith( - 'Dear {{customerName}}, thank you for contacting {{companyName}}. Reply to {{supportEmail}}.', - ['customerName' => 'Bob'] -); -// Outputs: "Dear Bob, thank you for contacting Acme Corp. Reply to support@acme.com." -``` - -## International Support - -The formatter fully supports Unicode characters in templates, placeholder names, and values: - -```php - -$formatter = new PlaceholderFormatter([ - 'nome' => 'João', - 'cidade' => 'São Paulo' -]); - -echo $formatter->format('{{nome}} mora em {{cidade}}'); -// Outputs: "João mora em São Paulo" - -$formatter = new PlaceholderFormatter([ - 'greeting' => 'مرحبا', - 'name' => 'أحمد' -]); - -echo $formatter->format('{{greeting}} {{name}}'); -// Outputs: "مرحبا أحمد" -``` +| Parameters | Template | Output | +| ---------------------- | ----------------------- | ------------------- | +| `['name' => 'John']` | `"Hello {{name}}!"` | `Hello John!` | +| `['x' => 1, 'y' => 2]` | `"{{x}} + {{y}}"` | `1 + 2` | +| `['name' => 'John']` | `"{{name}} is {{age}}"` | `John is {{age}}` | +| `[]` | `"Hello {{name}}"` | `Hello {{name}}` | +| `['active' => true]` | `"Active: {{active}}"` | ``Active: `true` `` | ## Limitations -- **No nested placeholders**: `{{outer{{inner}}}}` is not supported -- **No expressions**: `{{x + y}}` is not evaluated; only simple value replacement -- **No conditional logic**: No if/else or ternary operations -- **No default values**: Use null checks in PHP before passing parameters - -## Future Extensions - -The implementation is designed to support modifiers in a future phase: - -```php -// Future syntax (not yet implemented) -$formatter->format('Hello {{name|upper}}!'); -// Would output: "Hello JOHN!" -``` - -The regex pattern and internal structure are prepared for this extension without breaking changes. +- No nested placeholders: `{{outer{{inner}}}}` +- No expressions: `{{x + y}}` +- No conditional logic +- No default values syntax diff --git a/docs/contributing/testing-guidelines.md b/docs/contributing/testing-guidelines.md index d725522..59e0fc1 100644 --- a/docs/contributing/testing-guidelines.md +++ b/docs/contributing/testing-guidelines.md @@ -137,7 +137,7 @@ public function itShouldThrowExceptionForInvalidInput(): void - Create real instances of any objects needed for testing - Use custom test implementations instead of PHPUnit mocks -- Test through public APIs like `format()` and `formatWith()` +- Test through public APIs like `format()` and `formatUsing()` ### When Custom Implementations Are Needed diff --git a/docs/modifiers/AutoQuoteModifier.md b/docs/modifiers/AutoQuoteModifier.md new file mode 100644 index 0000000..2fe62c7 --- /dev/null +++ b/docs/modifiers/AutoQuoteModifier.md @@ -0,0 +1,45 @@ +# AutoQuoteModifier + +The `AutoQuoteModifier` automatically quotes string values by default, with the `|raw` pipe to bypass quoting. + +## Behavior + +| Pipe | String | Other Scalars | Non-Scalar | +| ------- | ---------------- | -------------------------- | -------------------------- | +| (none) | Quoted: `"John"` | Delegates to next modifier | Delegates to next modifier | +| `\|raw` | Unquoted: `John` | Returns as string | Delegates to next modifier | + +With `|raw`, booleans are converted to `1`/`0`. + +## Usage + +This modifier is not included in the default `PlaceholderFormatter` chain. To use it, create a chain with `StringifyModifier`: + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\AutoQuoteModifier; +use Respect\StringFormatter\Modifiers\StringifyModifier; + +$formatter = new PlaceholderFormatter( + ['firstname' => 'John', 'lastname' => 'Doe', 'active' => true], + new AutoQuoteModifier(new StringifyModifier()), +); + +echo $formatter->format('Hi {{firstname}} {{lastname|raw}}'); +// Output: Hi "John" Doe + +echo $formatter->format('Active: {{active}}, Raw: {{active|raw}}'); +// Output: Active: true, Raw: 1 +``` + +## Examples + +| Parameters | Template | Output | +| --------------------- | ---------------- | -------- | +| `['name' => 'John']` | `{{name}}` | `"John"` | +| `['name' => 'John']` | `{{name\|raw}}` | `John` | +| `['count' => 42]` | `{{count}}` | `42` | +| `['count' => 42]` | `{{count\|raw}}` | `42` | +| `['on' => true]` | `{{on}}` | `true` | +| `['on' => true]` | `{{on\|raw}}` | `1` | +| `['items' => [1, 2]]` | `{{items}}` | `[1, 2]` | diff --git a/docs/modifiers/CreatingCustomModifiers.md b/docs/modifiers/CreatingCustomModifiers.md new file mode 100644 index 0000000..cf24f06 --- /dev/null +++ b/docs/modifiers/CreatingCustomModifiers.md @@ -0,0 +1,99 @@ +# Creating Custom Modifiers + +Create custom modifiers by implementing the `Modifier` interface. + +## The Modifier Interface + +```php +namespace Respect\StringFormatter; + +interface Modifier +{ + public function modify(mixed $value, string|null $pipe): string; +} +``` + +- `$value`: The placeholder value to transform +- `$pipe`: The modifier name from the template (e.g., `"upper"` in `{{name|upper}}`) + +## Basic Example + +```php +use Respect\StringFormatter\Modifier; + +final readonly class UppercaseModifier implements Modifier +{ + public function __construct( + private Modifier $nextModifier, + ) { + } + + public function modify(mixed $value, string|null $pipe): string + { + if ($pipe === 'upper') { + return strtoupper($this->nextModifier->modify($value, null)); + } + + return $this->nextModifier->modify($value, $pipe); + } +} +``` + +## Usage + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\StringifyModifier; + +$formatter = new PlaceholderFormatter( + ['name' => 'John'], + new UppercaseModifier(new StringifyModifier()), +); + +echo $formatter->format('Hello {{name|upper}}'); +// Output: Hello JOHN +``` + +## Modifiers with Parameters + +Use `:` to pass parameters in the pipe: + +```php +final readonly class TruncateModifier implements Modifier +{ + public function __construct( + private Modifier $nextModifier, + ) { + } + + public function modify(mixed $value, string|null $pipe): string + { + if ($pipe !== null && str_starts_with($pipe, 'truncate:')) { + $length = (int) substr($pipe, 9); + $string = $this->nextModifier->modify($value, null); + + return strlen($string) > $length + ? substr($string, 0, $length) . '...' + : $string; + } + + return $this->nextModifier->modify($value, $pipe); + } +} +``` + +```php +$formatter = new PlaceholderFormatter( + ['text' => 'This is a very long piece of text'], + new TruncateModifier(new StringifyModifier()), +); + +echo $formatter->format('{{text|truncate:10}}'); +// Output: This is a... +``` + +## Key Points + +1. **Always chain to next modifier** - Call `$this->nextModifier->modify()` for unhandled pipes +2. **Handle null pipes** - `$pipe` is `null` when no modifier is specified +3. **Type safety** - Handle various input types gracefully diff --git a/docs/modifiers/ListModifier.md b/docs/modifiers/ListModifier.md new file mode 100644 index 0000000..f3e2614 --- /dev/null +++ b/docs/modifiers/ListModifier.md @@ -0,0 +1,43 @@ +# ListModifier + +The `|list` modifier formats arrays into human-readable lists with conjunctions. + +## Behavior + +| Array Size | Output Format | +| ---------- | --------------------------- | +| Empty | Delegates to next modifier | +| 1 item | `apple` | +| 2 items | `apple and banana` | +| 3+ items | `apple, banana, and cherry` | + +## Pipes + +- `|list` or `|list:and` - Uses :and as conjunction +- `|list:or` - Uses :or as conjunction + +## Usage + +```php +use Respect\StringFormatter\PlaceholderFormatter; + +$formatter = new PlaceholderFormatter([ + 'fruits' => ['apple', 'banana', 'cherry'], +]); + +echo $formatter->format('I like {{fruits|list}}'); +// Output: I like apple, banana, and cherry + +echo $formatter->format('Choose {{fruits|list:or}}'); +// Output: Choose apple, banana, or cherry +``` + +## Examples + +| Parameters | Template | Output | +| ------------------------------ | --------------------- | ------------- | +| `['items' => ['a']]` | `{{items\|list}}` | `a` | +| `['items' => ['a', 'b']]` | `{{items\|list}}` | `a and b` | +| `['items' => ['a', 'b']]` | `{{items\|list:or}}` | `a or b` | +| `['items' => ['a', 'b', 'c']]` | `{{items\|list:and}}` | `a, b, and c` | +| `['items' => ['a', 'b', 'c']]` | `{{items\|list:or}}` | `a, b, or c` | diff --git a/docs/modifiers/Modifiers.md b/docs/modifiers/Modifiers.md new file mode 100644 index 0000000..8f1b6c0 --- /dev/null +++ b/docs/modifiers/Modifiers.md @@ -0,0 +1,59 @@ +# PlaceholderFormatter Modifiers + +Modifiers transform placeholder values before they're inserted into the final string. They're applied using the `|` syntax: `{{placeholder|modifier}}`. + +## How Modifiers Work + +Modifiers form a chain where each modifier can: + +1. **Handle the value** and return a transformed string +2. **Pass the value** to the next modifier in the chain + +## Basic Usage + +```php +use Respect\StringFormatter\PlaceholderFormatter; + +$formatter = new PlaceholderFormatter([ + 'name' => 'John', + 'items' => ['apple', 'banana'], +]); + +echo $formatter->format('Hello {{name}}'); +// Output: Hello John + +echo $formatter->format('Items: {{items}}'); +// Output: Items: ["apple","banana"] +``` + +## Custom Modifier Chain + +You can specify a custom modifier chain when creating a `PlaceholderFormatter`: + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\AutoQuoteModifier; +use Respect\StringFormatter\Modifiers\StringifyModifier; + +$formatter = new PlaceholderFormatter( + ['name' => 'John'], + new AutoQuoteModifier(new StringifyModifier()), +); + +echo $formatter->format('Hello {{name}}'); +// Output: Hello "John" +``` + +If no modifier is provided, the formatter uses `StringifyModifier` by default. + +## Available Modifiers + +- **[AutoQuoteModifier](AutoQuoteModifier.md)** - Quotes string values by default, `|raw` bypasses quoting +- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions +- **[QuoteModifier](QuoteModifier.md)** - Quotes string values using a stringifier quoter +- **[StringifyModifier](StringifyModifier.md)** - Converts values to strings (default) +- **[TransModifier](TransModifier.md)** - Translates string values using a Symfony translator + +## Creating Custom Modifiers + +See [Creating Custom Modifiers](CreatingCustomModifiers.md) for implementing your own modifiers. diff --git a/docs/modifiers/QuoteModifier.md b/docs/modifiers/QuoteModifier.md new file mode 100644 index 0000000..65d6f6a --- /dev/null +++ b/docs/modifiers/QuoteModifier.md @@ -0,0 +1,53 @@ +# QuoteModifier + +The `|quote` modifier wraps string values with a configurable quote character (default: backtick `` ` ``). + +## Behavior + +- Strings are wrapped with the quote character and internal occurrences are escaped +- Non-string values delegate to the next modifier + +## Usage + +```php +use Respect\StringFormatter\PlaceholderFormatter; + +$formatter = new PlaceholderFormatter([ + 'name' => 'John', + 'text' => 'Say `hello`', + 'count' => 42, +]); + +echo $formatter->format('User: {{name|quote}}'); +// Output: User: `John` + +echo $formatter->format('{{text|quote}}'); +// Output: `Say \`hello\`` + +echo $formatter->format('{{count|quote}}'); +// Output: 42 (delegated to next modifier) +``` + +## Custom Quote Character + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\QuoteModifier; +use Respect\StringFormatter\Modifiers\StringifyModifier; + +$formatter = new PlaceholderFormatter( + ['name' => 'John'], + new QuoteModifier(new StringifyModifier(), "'"), +); + +echo $formatter->format('{{name|quote}}'); +// Output: 'John' +``` + +## Examples + +| Parameters | Template | Output | +| -------------------- | ----------------- | ------------ | +| `['name' => 'John']` | `{{name\|quote}}` | `` `John` `` | +| `['t' => 'a`b']` | `{{t\|quote}}` | `` `a\`b` `` | +| `['n' => 42]` | `{{n\|quote}}` | `42` | diff --git a/docs/modifiers/StringifyModifier.md b/docs/modifiers/StringifyModifier.md new file mode 100644 index 0000000..9f51f9e --- /dev/null +++ b/docs/modifiers/StringifyModifier.md @@ -0,0 +1,42 @@ +# StringifyModifier + +The `StringifyModifier` converts values to strings using a `Stringifier` instance. This is the default modifier used by `PlaceholderFormatter`. + +## Behavior + +- Strings pass through unchanged +- Other types are converted using the configured stringifier +- Throws `InvalidModifierPipeException` if an unrecognized pipe is passed + +## Usage + +```php +use Respect\StringFormatter\PlaceholderFormatter; + +$formatter = new PlaceholderFormatter([ + 'name' => 'John', + 'active' => true, + 'data' => ['x' => 1], +]); + +echo $formatter->format('{{name}} is {{active}}'); +// Output: John is true + +echo $formatter->format('Data: {{data}}'); +// Output: Data: ["x":1] +``` + +## Custom Stringifier + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\StringifyModifier; +use Respect\Stringifier\Stringifier; + +$formatter = new PlaceholderFormatter( + ['data' => $value], + new StringifyModifier($customStringifier), +); +``` + +See the [Respect\Stringifier documentation](https://github.com/Respect/Stringifier) for details on stringifiers. diff --git a/docs/modifiers/TransModifier.md b/docs/modifiers/TransModifier.md new file mode 100644 index 0000000..34a6014 --- /dev/null +++ b/docs/modifiers/TransModifier.md @@ -0,0 +1,53 @@ +# TransModifier + +The `|trans` modifier translates string values using a `TranslatorInterface` implementation. + +## Behavior + +- String values with `|trans` pipe are passed through the translator +- Non-string values delegate to the next modifier +- Missing translations return the original key unchanged + +## Usage + +By default, uses a `BypassTranslator` that returns the original input: + +```php +use Respect\StringFormatter\PlaceholderFormatter; + +$formatter = new PlaceholderFormatter(['message' => 'hello']); + +echo $formatter->format('{{message|trans}}'); +// Output: hello +``` + +## With Symfony Translator + +Install `symfony/translation` and inject a real translator: + +```php +use Respect\StringFormatter\PlaceholderFormatter; +use Respect\StringFormatter\Modifiers\TransModifier; +use Respect\StringFormatter\Modifiers\StringifyModifier; +use Symfony\Component\Translation\Translator; +use Symfony\Component\Translation\Loader\ArrayLoader; + +$translator = new Translator('en'); +$translator->addLoader('array', new ArrayLoader()); +$translator->addResource('array', ['greeting' => 'Hello World'], 'en'); + +$formatter = new PlaceholderFormatter( + ['key' => 'greeting'], + new TransModifier(new StringifyModifier(), $translator), +); + +echo $formatter->format('{{key|trans}}'); +// Output: Hello World +``` + +## Examples + +| Parameters | Template | Output | +| ----------------------- | ---------------- | ------------- | +| `['msg' => 'hello']` | `{{msg\|trans}}` | `hello` | +| `['key' => 'greeting']` | `{{key\|trans}}` | `Hello World` | diff --git a/src/BypassTranslator.php b/src/BypassTranslator.php new file mode 100644 index 0000000..88a4d11 --- /dev/null +++ b/src/BypassTranslator.php @@ -0,0 +1,31 @@ + $parameters */ + public function trans( + string $id, + array $parameters = [], + string|null $domain = null, + string|null $locale = null, + ): string { + return $id; + } + + public function getLocale(): string + { + return 'en'; + } +} diff --git a/src/Modifier.php b/src/Modifier.php new file mode 100644 index 0000000..f7e2fa6 --- /dev/null +++ b/src/Modifier.php @@ -0,0 +1,10 @@ +nextModifier->modify($value, null); + } + + return is_bool($value) ? (string) (int) $value : (string) $value; + } + + if ($pipe === null && is_string($value)) { + return '"' . addcslashes($value, '"') . '"'; + } + + return $this->nextModifier->modify($value, $pipe); + } +} diff --git a/src/Modifiers/InvalidModifierPipeException.php b/src/Modifiers/InvalidModifierPipeException.php new file mode 100644 index 0000000..cec337a --- /dev/null +++ b/src/Modifiers/InvalidModifierPipeException.php @@ -0,0 +1,12 @@ +nextModifier->modify($value, $pipe); + } + + if ($value === []) { + return $this->nextModifier->modify($value, $pipe); + } + + $modifiedValues = array_map(fn($item) => $this->nextModifier->modify($item, null), $value); + + $glue = match ($pipe) { + 'list:and', 'list' => 'and', + 'list:or' => 'or', + }; + + if (count($value) < 3) { + return implode(' ' . $glue . ' ', $modifiedValues); + } + + $last = array_pop($modifiedValues); + + return implode(', ', $modifiedValues) . ', ' . $glue . ' ' . $last; + } +} diff --git a/src/Modifiers/QuoteModifier.php b/src/Modifiers/QuoteModifier.php new file mode 100644 index 0000000..8edc4ab --- /dev/null +++ b/src/Modifiers/QuoteModifier.php @@ -0,0 +1,33 @@ +nextModifier->modify($value, $pipe); + } + + if (!is_scalar($value)) { + return $this->nextModifier->modify($value, null); + } + + return sprintf('%s%s%s', $this->quote, addcslashes((string) $value, $this->quote), $this->quote); + } +} diff --git a/src/Modifiers/StringifyModifier.php b/src/Modifiers/StringifyModifier.php new file mode 100644 index 0000000..8536a16 --- /dev/null +++ b/src/Modifiers/StringifyModifier.php @@ -0,0 +1,36 @@ +stringifier = $stringifier ?? HandlerStringifier::create(); + } + + public function modify(mixed $value, string|null $pipe): string + { + if ($pipe !== null) { + throw new InvalidModifierPipeException(sprintf('"%s" is not recognized as a valid pipe', $pipe)); + } + + if (is_string($value)) { + return $value; + } + + return $this->stringifier->stringify($value); + } +} diff --git a/src/Modifiers/TransModifier.php b/src/Modifiers/TransModifier.php new file mode 100644 index 0000000..7e6bd36 --- /dev/null +++ b/src/Modifiers/TransModifier.php @@ -0,0 +1,33 @@ +nextModifier->modify($value, $pipe); + } + + if (!is_string($value)) { + return $this->nextModifier->modify($value, null); + } + + return $this->translator->trans($value); + } +} diff --git a/src/PlaceholderFormatter.php b/src/PlaceholderFormatter.php index 9e550c4..b4312b6 100644 --- a/src/PlaceholderFormatter.php +++ b/src/PlaceholderFormatter.php @@ -4,8 +4,10 @@ namespace Respect\StringFormatter; -use Respect\Stringifier\HandlerStringifier; -use Respect\Stringifier\Stringifier; +use Respect\StringFormatter\Modifiers\ListModifier; +use Respect\StringFormatter\Modifiers\QuoteModifier; +use Respect\StringFormatter\Modifiers\StringifyModifier; +use Respect\StringFormatter\Modifiers\TransModifier; use function array_key_exists; use function is_string; @@ -13,29 +15,26 @@ final readonly class PlaceholderFormatter implements Formatter { - private Stringifier $stringifier; - /** @param array $parameters */ public function __construct( private array $parameters, - Stringifier|null $stringifier = null, + private Modifier $modifier = new TransModifier(new QuoteModifier(new ListModifier(new StringifyModifier()))), ) { - $this->stringifier = $stringifier ?? HandlerStringifier::create(); } public function format(string $input): string { - return $this->formatWithParameters($input, $this->parameters); + return $this->formatUsingParameters($input, $this->parameters); } /** @param array $parameters */ - public function formatWith(string $input, array $parameters): string + public function formatUsing(string $input, array $parameters): string { - return $this->formatWithParameters($input, $this->parameters + $parameters); + return $this->formatUsingParameters($input, $this->parameters + $parameters); } /** @param array $parameters */ - private function formatWithParameters(string $input, array $parameters): string + private function formatUsingParameters(string $input, array $parameters): string { return (string) preg_replace_callback( '/{{(\w+)(\|([^}]+))?}}/', @@ -50,17 +49,19 @@ private function formatWithParameters(string $input, array $parameters): string */ private function processPlaceholder(array $matches, array $parameters): string { - [$placeholder, $name] = $matches; + $placeholder = $matches[0] ?? ''; + $name = $matches[1] ?? ''; + $pipe = $matches[3] ?? null; if (!array_key_exists($name, $parameters)) { return $placeholder; } $value = $parameters[$name]; - if (is_string($value)) { + if (is_string($value) && $pipe === null) { return $value; } - return $this->stringifier->stringify($value); + return $this->modifier->modify($value, $pipe); } } diff --git a/tests/Helper/TestingModifier.php b/tests/Helper/TestingModifier.php new file mode 100644 index 0000000..c2e7569 --- /dev/null +++ b/tests/Helper/TestingModifier.php @@ -0,0 +1,22 @@ +customResult ?? ($pipe ? sprintf('%s(%s)', $pipe, print_r($value, true)) : print_r($value, true)); + } +} diff --git a/tests/Helper/TestingQuoter.php b/tests/Helper/TestingQuoter.php new file mode 100644 index 0000000..363a912 --- /dev/null +++ b/tests/Helper/TestingQuoter.php @@ -0,0 +1,24 @@ +result = $result ?? uniqid(); + } + + public function quote(string $string, int $depth): string + { + return $this->result; + } +} diff --git a/tests/Helper/TestingStringifier.php b/tests/Helper/TestingStringifier.php new file mode 100644 index 0000000..7d8ee92 --- /dev/null +++ b/tests/Helper/TestingStringifier.php @@ -0,0 +1,24 @@ +result = $result ?? uniqid(); + } + + public function stringify(mixed $raw): string + { + return $this->result; + } +} diff --git a/tests/Helper/TestingTranslator.php b/tests/Helper/TestingTranslator.php new file mode 100644 index 0000000..08fae56 --- /dev/null +++ b/tests/Helper/TestingTranslator.php @@ -0,0 +1,44 @@ + $translations */ + public function __construct( + private array $translations = [], + ) { + } + + /** @param array $parameters */ + public function trans( + string $id, + array $parameters = [], + string|null $domain = null, + string|null $locale = null, + ): string { + return $this->translations[$id] ?? $id; + } + + public function getLocale(): string + { + return 'en'; + } + + public function setLocale(string $locale): void + { + // Dummy implementation - not needed for testing + } +} diff --git a/tests/Unit/BypassTranslatorTest.php b/tests/Unit/BypassTranslatorTest.php new file mode 100644 index 0000000..9f442c3 --- /dev/null +++ b/tests/Unit/BypassTranslatorTest.php @@ -0,0 +1,102 @@ +translator = new BypassTranslator(); + } + + #[Test] + public function itShouldReturnOriginalIdForTranslation(): void + { + $id = 'some.translation.key'; + $parameters = ['param1' => 'value1']; + $domain = 'messages'; + $locale = 'en'; + + $result = $this->translator->trans($id, $parameters, $domain, $locale); + + self::assertSame($id, $result); + } + + #[Test] + public function itShouldReturnOriginalIdForTranslationWithMinimalParameters(): void + { + $id = 'simple.key'; + + $result = $this->translator->trans($id); + + self::assertSame($id, $result); + } + + #[Test] + public function itShouldReturnOriginalIdForTranslationWithEmptyParameters(): void + { + $id = 'key.with.no.params'; + + $result = $this->translator->trans($id, []); + + self::assertSame($id, $result); + } + + #[Test] + public function itShouldReturnOriginalIdForTranslationWithNullDomainAndLocale(): void + { + $id = 'key.with.nulls'; + + $result = $this->translator->trans($id, ['param' => 'value'], null, null); + + self::assertSame($id, $result); + } + + #[Test] + public function itShouldAlwaysReturnEnglishAsDefaultLocale(): void + { + $locale = $this->translator->getLocale(); + + self::assertSame('en', $locale); + } + + #[Test] + public function itShouldHandleEmptyStringTranslation(): void + { + $result = $this->translator->trans(''); + + self::assertSame('', $result); + } + + #[Test] + public function itShouldHandleComplexTranslationKeyId(): void + { + $complexId = 'nested.deep.very.complex.translation.key.with.dots'; + + $result = $this->translator->trans($complexId, ['param' => 'value'], 'domain', 'fr_FR'); + + self::assertSame($complexId, $result); + } + + #[Test] + public function itShouldHandleSpecialCharactersInTranslationKey(): void + { + $specialId = 'key.with-special_chars@123#$%'; + + $result = $this->translator->trans($specialId); + + self::assertSame($specialId, $result); + } +} diff --git a/tests/Unit/Modifiers/AutoQuoteModifierTest.php b/tests/Unit/Modifiers/AutoQuoteModifierTest.php new file mode 100644 index 0000000..33866d4 --- /dev/null +++ b/tests/Unit/Modifiers/AutoQuoteModifierTest.php @@ -0,0 +1,134 @@ +modify('some string', null); + + self::assertSame('"some string"', $actual); + } + + #[Test] + public function itShouldEscapeDoubleQuotesInStringValues(): void + { + $modifier = new AutoQuoteModifier(new TestingModifier()); + + $actual = $modifier->modify('say "hello"', null); + + self::assertSame('"say \"hello\""', $actual); + } + + #[Test] + #[DataProvider('providerForNonStringScalarValues')] + public function itShouldDelegateToNextModifierWhenValueIsNonStringScalarAndPipeIsNull(mixed $value): void + { + $nextModifier = new TestingModifier(); + $modifier = new AutoQuoteModifier($nextModifier); + $expected = $nextModifier->modify($value, null); + + $actual = $modifier->modify($value, null); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForScalarValuesWithRawPipe')] + public function itShouldReturnScalarValuesAsStringWhenPipeIsRaw(mixed $value, string $expected): void + { + $modifier = new AutoQuoteModifier(new TestingModifier()); + + $actual = $modifier->modify($value, 'raw'); + + self::assertSame($expected, $actual); + } + + #[Test] + public function itShouldDelegateStringToNextModifierWhenPipeIsNotRawOrNull(): void + { + $nextModifier = new TestingModifier(); + $modifier = new AutoQuoteModifier($nextModifier); + $value = 'some value'; + $pipe = 'other'; + $expected = $nextModifier->modify($value, $pipe); + + $actual = $modifier->modify($value, $pipe); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNonScalarValues')] + public function itShouldDelegateToNextModifierWhenValueIsNotScalarAndPipeIsNull(mixed $value): void + { + $nextModifier = new TestingModifier(); + $modifier = new AutoQuoteModifier($nextModifier); + $expected = $nextModifier->modify($value, null); + + $actual = $modifier->modify($value, null); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNonScalarValues')] + public function itShouldDelegateToNextModifierWhenValueIsNotScalarAndPipeIsRaw(mixed $value): void + { + $nextModifier = new TestingModifier(); + $modifier = new AutoQuoteModifier($nextModifier); + $expected = $nextModifier->modify($value, null); + + $actual = $modifier->modify($value, 'raw'); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForScalarValuesWithRawPipe(): array + { + return [ + 'string' => ['some string', 'some string'], + 'integer' => [123, '123'], + 'float' => [123.456, '123.456'], + 'boolean true' => [true, '1'], + 'boolean false' => [false, '0'], + ]; + } + + /** @return array */ + public static function providerForNonStringScalarValues(): array + { + return [ + 'integer' => [123], + 'float' => [123.456], + 'boolean true' => [true], + 'boolean false' => [false], + ]; + } + + /** @return array */ + public static function providerForNonScalarValues(): array + { + return [ + 'array' => [['not', 'scalar']], + 'object' => [new stdClass()], + 'null' => [null], + ]; + } +} diff --git a/tests/Unit/Modifiers/ListModifierTest.php b/tests/Unit/Modifiers/ListModifierTest.php new file mode 100644 index 0000000..f17fff0 --- /dev/null +++ b/tests/Unit/Modifiers/ListModifierTest.php @@ -0,0 +1,110 @@ +modify($value, $pipe); + + self::assertSame($nextModifier->modify($value, $pipe), $result); + } + + /** @return array */ + public static function providerNonSupportedValuesAndPipes(): array + { + return [ + 'pipe is null' => [null, ['a', 'b', 'c']], + 'pipe is not list' => ['notList', ['a', 'b', 'c']], + 'value is not array' => ['list:and', 'not an array'], + 'value is empty array' => ['list:or', []], + 'modifier is not well formatted' => ['list(and")', []], + ]; + } + + /** @param array $value */ + #[Test] + #[DataProvider('providerSupportedValuesAndPipes')] + public function itShouldModifyValue(string $pipe, array $value, string $expected): void + { + $modifier = new ListModifier(new TestingModifier()); + + $result = $modifier->modify($value, $pipe); + + self::assertSame($expected, $result); + } + + /** @return array, 2: string}> */ + public static function providerSupportedValuesAndPipes(): array + { + return [ + 'with a single value' => [ + 'list', + ['apple'], + 'apple', + ], + ':and with a single value' => [ + 'list:and', + ['apple'], + 'apple', + ], + ':or with a single value' => [ + 'list:or', + ['apple'], + 'apple', + ], + 'with two values' => [ + 'list', + ['apple', 'banana'], + 'apple and banana', + ], + ':and with two values' => [ + 'list:and', + ['apple', 'banana'], + 'apple and banana', + ], + ':or with two values' => [ + 'list:or', + ['apple', 'banana'], + 'apple or banana', + ], + 'with multiple values' => [ + 'list', + ['apple', 'banana', 'cherry', 'date', 'elderberry'], + 'apple, banana, cherry, date, and elderberry', + ], + ':and with multiple values' => [ + 'list:and', + ['apple', 'banana', 'cherry', 'date', 'elderberry'], + 'apple, banana, cherry, date, and elderberry', + ], + ':or with multiple values' => [ + 'list:or', + ['apple', 'banana', 'cherry', 'date', 'elderberry'], + 'apple, banana, cherry, date, or elderberry', + ], + 'with associative array' => [ + 'list', + ['a' => 'apple', 'b' => 'banana', 'c' => 'cherry', 'd' => 'date', 'e' => 'elderberry'], + 'apple, banana, cherry, date, and elderberry', + ], + ]; + } +} diff --git a/tests/Unit/Modifiers/QuoteModifierTest.php b/tests/Unit/Modifiers/QuoteModifierTest.php new file mode 100644 index 0000000..728bbfa --- /dev/null +++ b/tests/Unit/Modifiers/QuoteModifierTest.php @@ -0,0 +1,122 @@ +modify($value, $pipe); + + $actual = $modifier->modify($value, $pipe); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForPipeNotQuoteCases(): array + { + return [ + 'non-string pipe value' => ['some string', 'notQuote'], + 'null pipe value' => ['some string', null], + ]; + } + + #[Test] + #[DataProvider('providerForNonScalarWithQuotePipe')] + public function itShouldDelegateToNextModifierWhenValueIsNotScalarAndPipeIsQuote(mixed $value): void + { + $nextModifier = new TestingModifier(); + $modifier = new QuoteModifier($nextModifier); + // Non-scalar values with 'quote' pipe should delegate with null pipe + $expected = $nextModifier->modify($value, null); + + $actual = $modifier->modify($value, 'quote'); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForNonScalarWithQuotePipe(): array + { + return [ + 'array value with quote pipe' => [['not', 'a', 'string']], + 'null value with quote pipe' => [null], + 'object value with quote pipe' => [(object) ['key' => 'value']], + ]; + } + + #[Test] + #[DataProvider('providerForScalarQuotingCases')] + public function itShouldQuoteScalarValuesWithQuotePipe( + mixed $value, + string $expected, + ): void { + $modifier = new QuoteModifier(new TestingModifier()); + + $actual = $modifier->modify($value, 'quote'); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForScalarQuotingCases(): array + { + return [ + 'integer value' => [42, '`42`'], + 'float value' => [3.14, '`3.14`'], + 'boolean true value' => [true, '`1`'], + 'boolean false value' => [false, '``'], + ]; + } + + #[Test] + #[DataProvider('providerForStringQuoting')] + public function itShouldQuoteStringsWithVariousContent(string $input, string $expected, string $quote = '`'): void + { + $modifier = new QuoteModifier(new TestingModifier(), $quote); + + $actual = $modifier->modify($input, 'quote'); + + self::assertSame($expected, $actual); + } + + /** @return array */ + public static function providerForStringQuoting(): array + { + return [ + 'simple string' => ['hello', '`hello`', '`'], + 'empty string' => ['', '``', '`'], + 'string with backticks' => ['he `hello` there', '`he \\`hello\\` there`', '`'], + 'string with backslashes' => ['path\\to\\file', '`path\\to\\file`', '`'], + 'string with special characters' => ['!@#$%^&*()', '`!@#$%^&*()`', '`'], + 'string with newlines' => ["line1\nline2", "`line1\nline2`", '`'], + 'unicode characters' => ['héllo 🌍', '`héllo 🌍`', '`'], + 'emoji string' => ['😀🎉', '`😀🎉`', '`'], + 'mixed language string' => ['Hello 世界', '`Hello 世界`', '`'], + 'html entities' => ['<div>', '`<div>`', '`'], + 'url string' => ['https://example.com', '`https://example.com`', '`'], + 'email string' => ['user@example.com', '`user@example.com`', '`'], + 'single quote string' => ["don't", "`don't`", '`'], + 'string with single quotes' => ["he's 'great'", "`he's 'great'`", '`'], + 'string with mixed quotes' => ['say "hello" and \'goodbye\'', "`say \"hello\" and 'goodbye'`", '`'], + 'test with single quotes' => ["can't stop", '"can\'t stop"', '"'], + 'test with double quotes' => ['hello "world"', '"hello \"world\""', '"'], + 'test with both quotes' => ['hello "world" and \'test\'', '"hello \"world\" and \'test\'"', '"'], + ]; + } +} diff --git a/tests/Unit/Modifiers/StringifyModifierTest.php b/tests/Unit/Modifiers/StringifyModifierTest.php new file mode 100644 index 0000000..ecc30f9 --- /dev/null +++ b/tests/Unit/Modifiers/StringifyModifierTest.php @@ -0,0 +1,81 @@ + */ + public static function providerForModifiableValues(): array + { + return [ + 'array value' => [['a', 'b', 'c']], + 'integer value' => [42], + 'float value' => [3.14159], + 'boolean true' => [true], + 'boolean false' => [false], + 'null value' => [null], + 'object value' => [(object) ['key' => 'value']], + 'empty array' => [[]], + 'resource' => [fopen('php://memory', 'r')], + ]; + } + + /** @return array */ + public static function providerForNonModifiableValues(): array + { + return [ + 'string value' => ['test string'], + 'empty string' => [''], + ]; + } + + #[Test] + #[DataProvider('providerForModifiableValues')] + public function itShouldDelegateToStringifierWhenValueIsNotString(mixed $value): void + { + $expected = uniqid(); + $stringifier = new TestingStringifier($expected); + $modifier = new StringifyModifier($stringifier); + + $actual = $modifier->modify($value, null); + + self::assertSame($expected, $actual); + } + + #[Test] + #[DataProvider('providerForNonModifiableValues')] + public function itShouldByPassTheStringifierWhenValueIsString(string $value): void + { + $modifier = new StringifyModifier(new TestingStringifier()); + + $actual = $modifier->modify($value, null); + + self::assertSame($value, $actual); + } + + #[Test] + public function itShouldThrowExceptionWhenPipeIsNotNull(): void + { + $modifier = new StringifyModifier(new TestingStringifier()); + $pipe = 'existing_pipe_value'; + + $this->expectException(InvalidModifierPipeException::class); + $this->expectExceptionMessage('"existing_pipe_value" is not recognized as a valid pipe'); + + $modifier->modify('test', $pipe); + } +} diff --git a/tests/Unit/Modifiers/TransModifierTest.php b/tests/Unit/Modifiers/TransModifierTest.php new file mode 100644 index 0000000..d144faa --- /dev/null +++ b/tests/Unit/Modifiers/TransModifierTest.php @@ -0,0 +1,116 @@ +modify($value, $expectedPipe); + + $actual = $modifier->modify($value, $pipe); + + self::assertSame($expected, $actual); + } + + /** @param array $translations */ + #[Test] + #[DataProvider('providerForTranslationCases')] + public function itShouldTranslateUsingCustomTranslator(string $value, string $expected, array $translations): void + { + $nextModifier = new TestingModifier(); + $modifier = new TransModifier($nextModifier, new TestingTranslator($translations)); + $pipe = 'trans'; + + $actual = $modifier->modify($value, $pipe); + + self::assertSame($expected, $actual); + } + + #[Test] + public function itShouldDelegateToNextModifierWhenTranslationNotFound(): void + { + $nextModifier = new TestingModifier(); + $modifier = new TransModifier($nextModifier, new TestingTranslator([ + 'hello' => 'Hello World', + 'welcome' => 'Welcome', + ])); + $unknownKey = 'nonexistent_key'; + $pipe = 'trans'; + $expected = $nextModifier->modify($unknownKey, null); + + $actual = $modifier->modify($unknownKey, $pipe); + + self::assertSame($expected, $actual); + } + + /** @return array}> */ + public static function providerForTranslationCases(): array + { + return [ + 'hello translation' => ['hello', 'Hello World', ['hello' => 'Hello World']], + 'welcome translation' => ['welcome', 'Welcome', ['welcome' => 'Welcome']], + 'goodbye translation' => ['goodbye', 'Goodbye', ['goodbye' => 'Goodbye']], + 'empty string' => ['', '', ['' => '']], + 'multiple translations available' => [ + 'hello', + 'Hello World', + [ + 'hello' => 'Hello World', + 'welcome' => 'Welcome', + 'goodbye' => 'Goodbye', + ], + ], + ]; + } + + /** @return array */ + public static function providerForDelegationCases(): array + { + return [ + 'pipe is not trans' => ['hello', 'notTrans'], + 'pipe is null' => ['hello', null], + 'value is array' => [['a', 'b'], 'trans'], + 'value is integer' => [42, 'trans'], + 'value is float' => [3.14, 'trans'], + 'value is boolean true' => [true, 'trans'], + 'value is boolean false' => [false, 'trans'], + 'value is null' => [null, 'trans'], + 'value is object' => [(object) ['key' => 'value'], 'trans'], + ]; + } + + /** @return array */ + public static function providerForNonStringValues(): array + { + return [ + 'array' => [['a', 'b']], + 'integer' => [42], + 'float' => [3.14], + 'boolean true' => [true], + 'boolean false' => [false], + 'null' => [null], + 'object' => [(object) ['key' => 'value']], + ]; + } +} diff --git a/tests/Unit/PlaceholderFormatterTest.php b/tests/Unit/PlaceholderFormatterTest.php index 620aa31..c52000a 100644 --- a/tests/Unit/PlaceholderFormatterTest.php +++ b/tests/Unit/PlaceholderFormatterTest.php @@ -9,10 +9,12 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Respect\StringFormatter\PlaceholderFormatter; -use Respect\Stringifier\DumpStringifier; +use Respect\StringFormatter\Test\Helper\TestingModifier; use stdClass; use Stringable; +use function sprintf; + #[CoversClass(PlaceholderFormatter::class)] final class PlaceholderFormatterTest extends TestCase { @@ -384,15 +386,18 @@ public static function providerForUnicodeSupport(): array } #[Test] - public function itShouldAcceptCustomStringifier(): void + public function itShouldAcceptCustomModifier(): void { - $stringifier = new DumpStringifier(); $value = new stdClass(); + $pipe = 'pipe'; + $placeholder = 'placeholder'; + + $modifier = new TestingModifier(); - $expected = 'The value is ' . $stringifier->stringify($value); + $expected = 'The value is ' . $modifier->modify($value, $pipe); - $formatter = new PlaceholderFormatter(['value' => $value], $stringifier); - $actual = $formatter->format('The value is {{value}}'); + $formatter = new PlaceholderFormatter([$placeholder => $value], $modifier); + $actual = $formatter->format(sprintf('The value is {{%s|%s}}', $placeholder, $pipe)); self::assertSame($expected, $actual); } @@ -462,7 +467,7 @@ public function itShouldFormatWithAdditionalParameters( string $expected, ): void { $formatter = new PlaceholderFormatter($constructorParameters); - $actual = $formatter->formatWith($template, $additionalParameters); + $actual = $formatter->formatUsing($template, $additionalParameters); self::assertSame($expected, $actual); } @@ -539,8 +544,8 @@ public function itShouldNotModifyOriginalFormatterBehavior(): void { $formatter = new PlaceholderFormatter(['name' => 'John']); - // Call formatWith first - $withResult = $formatter->formatWith('Hello {{name}} and {{other}}!', ['other' => 'World']); + // Call formatUsing first + $withResult = $formatter->formatUsing('Hello {{name}} and {{other}}!', ['other' => 'World']); // Then call format - should still work with original parameters only $formatResult = $formatter->format('Hello {{name}} and {{other}}!');