From f5f216aaafa18272d831651ae0728fb21e9afadc Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Mon, 19 Jan 2026 11:53:54 +0100 Subject: [PATCH] Implement PlaceholderFormatter for template interpolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I wanted to create a way to build dynamic, context-aware messages, similar to what we currently have in the Validation component. My long-term goal is to decouple string formatting from Validation and centralize it here in StringFormatter. This commit introduces the `PlaceholderFormatter`, which brings a familiar `{{placeholder}}` syntax to our toolkit. Recognizing that creating a new object for every unique template can be cumbersome, I have introduced the `formatWith()` method. This allows for additional data injection at call-time. However, to maintain predictability, I’ve established a clear hierarchy: parameters provided in the constructor are treated as the "primary" configuration and will always take precedence over call-time parameters. This ensures that a pre-configured formatter behaves consistently even when used in a dynamic context. By moving this logic here, we lay the groundwork for a more modular system where formatting is a first-class citizen rather than a utility hidden inside other components. Assisted-by: Claude Code (Opus 4.5) --- README.md | 9 +- composer.json | 3 +- docs/PlaceholderFormatter.md | 380 +++++++++++++++ src/PlaceholderFormatter.php | 66 +++ tests/Unit/PlaceholderFormatterTest.php | 589 ++++++++++++++++++++++++ 5 files changed, 1042 insertions(+), 5 deletions(-) create mode 100644 docs/PlaceholderFormatter.md create mode 100644 src/PlaceholderFormatter.php create mode 100644 tests/Unit/PlaceholderFormatterTest.php diff --git a/README.md b/README.md index 62c48da..95d055c 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ composer require respect/string-formatter ## Formatters -| Formatter | Description | -| -------------------------------------------- | ------------------------------------------------ | -| [MaskFormatter](docs/MaskFormatter.md) | Range-based string masking with Unicode support | -| [PatternFormatter](docs/PatternFormatter.md) | Pattern-based string filtering with placeholders | +| Formatter | Description | +| ---------------------------------------------------- | --------------------------------------------------- | +| [MaskFormatter](docs/MaskFormatter.md) | Range-based string masking with Unicode support | +| [PatternFormatter](docs/PatternFormatter.md) | Pattern-based string filtering with placeholders | +| [PlaceholderFormatter](docs/PlaceholderFormatter.md) | Template interpolation with placeholder replacement | ## Contributing diff --git a/composer.json b/composer.json index 4887fc3..1fb8a12 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,8 @@ "description": "A powerful and flexible way of formatting and transforming strings", "require": { "symfony/polyfill-mbstring": "^1.33", - "php": "^8.5" + "php": "^8.5", + "respect/stringifier": "^3.0" }, "require-dev": { "phpunit/phpunit": "^12.5", diff --git a/docs/PlaceholderFormatter.md b/docs/PlaceholderFormatter.md new file mode 100644 index 0000000..8e5ae23 --- /dev/null +++ b/docs/PlaceholderFormatter.md @@ -0,0 +1,380 @@ +# 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. + +## 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]" +``` + +### Using Additional Parameters + +The `formatWith` 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( + 'Welcome to {{siteName}} - Hello {{userName}}! © {{year}}', + ['userName' => 'John', 'year' => 2020] // year won't override constructor value +); +// Outputs: "Welcome to MyApp - Hello John! © 2024" +``` + +## API + +### `PlaceholderFormatter::__construct` + +- `__construct(array $parameters, Stringifier|null $stringifier = null)` + +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 + +**Returns:** The formatted string with placeholders replaced by their values + +### `formatWith` + +- `formatWith(string $input, array $parameters): string` + +Formats the template string with additional parameters merged with constructor parameters. Constructor parameters take precedence and won't be overwritten by additional parameters. + +**Parameters:** + +- `$input`: The template string containing placeholders +- `$parameters`: Additional associative array of placeholder names to values + +**Returns:** The formatted string with placeholders replaced by their values + +**Behavior:** + +- 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 + +## Template Syntax + +### Placeholder Format + +Placeholders follow the format `{{name}}` where `name` is a valid parameter key. + +**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:** + +- `{{name}}` +- `{{firstName}}` +- `{{value123}}` +- `{{user_id}}` + +**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}}"` | + +## 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 + +## 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: "مرحبا أحمد" +``` + +## 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. diff --git a/src/PlaceholderFormatter.php b/src/PlaceholderFormatter.php new file mode 100644 index 0000000..9e550c4 --- /dev/null +++ b/src/PlaceholderFormatter.php @@ -0,0 +1,66 @@ + $parameters */ + public function __construct( + private array $parameters, + Stringifier|null $stringifier = null, + ) { + $this->stringifier = $stringifier ?? HandlerStringifier::create(); + } + + public function format(string $input): string + { + return $this->formatWithParameters($input, $this->parameters); + } + + /** @param array $parameters */ + public function formatWith(string $input, array $parameters): string + { + return $this->formatWithParameters($input, $this->parameters + $parameters); + } + + /** @param array $parameters */ + private function formatWithParameters(string $input, array $parameters): string + { + return (string) preg_replace_callback( + '/{{(\w+)(\|([^}]+))?}}/', + fn(array $matches) => $this->processPlaceholder($matches, $parameters), + $input, + ); + } + + /** + * @param array $matches + * @param array $parameters + */ + private function processPlaceholder(array $matches, array $parameters): string + { + [$placeholder, $name] = $matches; + + if (!array_key_exists($name, $parameters)) { + return $placeholder; + } + + $value = $parameters[$name]; + if (is_string($value)) { + return $value; + } + + return $this->stringifier->stringify($value); + } +} diff --git a/tests/Unit/PlaceholderFormatterTest.php b/tests/Unit/PlaceholderFormatterTest.php new file mode 100644 index 0000000..620aa31 --- /dev/null +++ b/tests/Unit/PlaceholderFormatterTest.php @@ -0,0 +1,589 @@ + $parameters */ + #[Test] + #[DataProvider('providerForBasicInterpolation')] + public function itShouldInterpolateBasicTemplates(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForBasicInterpolation(): array + { + return [ + 'single placeholder' => [ + ['name' => 'John'], + 'Hello {{name}}!', + 'Hello John!', + ], + 'multiple placeholders' => [ + ['name' => 'John', 'age' => 30], + 'Hello {{name}}, you are {{age}} years old.', + 'Hello John, you are 30 years old.', + ], + 'repeated placeholder' => [ + ['name' => 'Alice'], + '{{name}} loves {{name}}!', + 'Alice loves Alice!', + ], + 'placeholder at start' => [ + ['greeting' => 'Hello'], + '{{greeting}} World', + 'Hello World', + ], + 'placeholder at end' => [ + ['name' => 'Bob'], + 'Hello {{name}}', + 'Hello Bob', + ], + 'only placeholder' => [ + ['value' => 'test'], + '{{value}}', + 'test', + ], + 'multiple placeholders in sequence' => [ + ['first' => 'A', 'second' => 'B', 'third' => 'C'], + '{{first}}{{second}}{{third}}', + 'ABC', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForMissingParameters')] + public function itShouldKeepPlaceholderForMissingParameters( + array $parameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForMissingParameters(): array + { + return [ + 'missing parameter' => [ + ['name' => 'John'], + 'Hello {{name}}, you are {{age}} years old.', + 'Hello John, you are {{age}} years old.', + ], + 'all missing parameters' => [ + [], + 'Hello {{name}}, you are {{age}} years old.', + 'Hello {{name}}, you are {{age}} years old.', + ], + 'mixed existing and missing' => [ + ['first' => 'A', 'third' => 'C'], + '{{first}}-{{second}}-{{third}}', + 'A-{{second}}-C', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForNullValues')] + public function itShouldConvertNullValuesToString(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForNullValues(): array + { + return [ + 'null value' => [ + ['name' => null], + 'Hello {{name}}!', + 'Hello `null`!', + ], + 'mixed null and non-null' => [ + ['first' => 'A', 'second' => null, 'third' => 'C'], + '{{first}}-{{second}}-{{third}}', + 'A-`null`-C', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForTypeConversions')] + public function itShouldConvertTypesToStrings(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForTypeConversions(): array + { + $stringable = new class implements Stringable { + public function __toString(): string + { + return 'StringableObject'; + } + }; + + return [ + 'integer value' => [ + ['count' => 42], + 'The count is {{count}}', + 'The count is 42', + ], + 'float value' => [ + ['price' => 19.99], + 'Price: ${{price}}', + 'Price: $19.99', + ], + 'boolean true' => [ + ['active' => true], + 'Active: {{active}}', + 'Active: `true`', + ], + 'boolean false' => [ + ['active' => false], + 'Active: {{active}}', + 'Active: `false`', + ], + 'empty string' => [ + ['value' => ''], + 'Value: [{{value}}]', + 'Value: []', + ], + 'zero integer' => [ + ['value' => 0], + 'Value: {{value}}', + 'Value: 0', + ], + 'zero float' => [ + ['value' => 0.0], + 'Value: {{value}}', + 'Value: 0.0', + ], + 'stringable object' => [ + ['obj' => $stringable], + 'Object: {{obj}}', + 'Object: `Stringable@anonymous { __toString() => "StringableObject" }`', + ], + ]; + } + + #[Test] + public function itShouldConvertArrayToString(): void + { + $formatter = new PlaceholderFormatter(['items' => ['a', 'b', 'c']]); + $actual = $formatter->format('Items: {{items}}'); + + self::assertStringContainsString('a', $actual); + self::assertStringContainsString('b', $actual); + self::assertStringContainsString('c', $actual); + } + + #[Test] + public function itShouldConvertObjectToString(): void + { + $obj = new stdClass(); + $obj->name = 'test'; + + $formatter = new PlaceholderFormatter(['obj' => $obj]); + $actual = $formatter->format('Object: {{obj}}'); + + self::assertStringContainsString('stdClass', $actual); + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForEdgeCases')] + public function itShouldHandleEdgeCases(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForEdgeCases(): array + { + return [ + 'empty template' => [ + ['name' => 'John'], + '', + '', + ], + 'no placeholders' => [ + ['name' => 'John'], + 'Hello World!', + 'Hello World!', + ], + 'empty parameters with template' => [ + [], + 'No {{placeholders}} here', + 'No {{placeholders}} here', + ], + 'placeholder with numbers in name' => [ + ['value1' => 'A', 'value2' => 'B'], + '{{value1}} and {{value2}}', + 'A and B', + ], + 'placeholder with underscore' => [ + ['first_name' => 'John', 'last_name' => 'Doe'], + '{{first_name}} {{last_name}}', + 'John Doe', + ], + 'template with only text' => [ + ['unused' => 'value'], + 'Just plain text', + 'Just plain text', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForMalformedPlaceholders')] + public function itShouldKeepMalformedPlaceholdersAsLiterals( + array $parameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForMalformedPlaceholders(): array + { + return [ + 'single brace' => [ + ['name' => 'John'], + 'Hello {name}!', + 'Hello {name}!', + ], + 'triple braces' => [ + ['name' => 'John'], + 'Hello {{{name}}}!', + 'Hello {John}!', + ], + 'opening braces only' => [ + ['name' => 'John'], + 'Hello {{name!', + 'Hello {{name!', + ], + 'closing braces only' => [ + ['name' => 'John'], + 'Hello name}}!', + 'Hello name}}!', + ], + 'placeholder with spaces' => [ + ['name' => 'John'], + 'Hello {{ name }}!', + 'Hello {{ name }}!', + ], + 'placeholder with special chars' => [ + ['name' => 'John'], + 'Hello {{name-value}}!', + 'Hello {{name-value}}!', + ], + 'empty placeholder' => [ + ['name' => 'John'], + 'Hello {{}}!', + 'Hello {{}}!', + ], + ]; + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForUnicodeSupport')] + public function itShouldSupportUnicode(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForUnicodeSupport(): array + { + return [ + 'unicode in template' => [ + ['name' => 'José'], + 'Hola {{name}}!', + 'Hola José!', + ], + 'unicode in value' => [ + ['greeting' => 'Привет'], + '{{greeting}} World', + 'Привет World', + ], + 'unicode in placeholder name' => [ + ['nombre' => 'Juan'], + 'Hola {{nombre}}!', + 'Hola Juan!', + ], + 'emoji in template' => [ + ['emoji' => '🎉'], + 'Celebration {{emoji}}', + 'Celebration 🎉', + ], + 'emoji in value' => [ + ['icon' => '🔥'], + 'Hot {{icon}}', + 'Hot 🔥', + ], + 'mixed unicode characters' => [ + ['text' => 'Здравствуй мир'], + 'Message: {{text}}', + 'Message: Здравствуй мир', + ], + 'chinese characters' => [ + ['greeting' => '你好'], + '{{greeting}},世界', + '你好,世界', + ], + 'arabic characters' => [ + ['text' => 'مرحبا'], + '{{text}} world', + 'مرحبا world', + ], + ]; + } + + #[Test] + public function itShouldAcceptCustomStringifier(): void + { + $stringifier = new DumpStringifier(); + $value = new stdClass(); + + $expected = 'The value is ' . $stringifier->stringify($value); + + $formatter = new PlaceholderFormatter(['value' => $value], $stringifier); + $actual = $formatter->format('The value is {{value}}'); + + self::assertSame($expected, $actual); + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForRealWorldUseCases')] + public function itShouldHandleRealWorldUseCases(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForRealWorldUseCases(): array + { + return [ + 'email template' => [ + ['name' => 'Alice', 'product' => 'Widget', 'price' => 29.99], + 'Dear {{name}}, your order for {{product}} (${{price}}) has been confirmed.', + 'Dear Alice, your order for Widget ($29.99) has been confirmed.', + ], + 'log message' => [ + ['user' => 'admin', 'action' => 'login', 'ip' => '192.168.1.1'], + '[{{user}}] {{action}} from {{ip}}', + '[admin] login from 192.168.1.1', + ], + 'notification message' => [ + ['count' => 5, 'type' => 'messages'], + 'You have {{count}} new {{type}}.', + 'You have 5 new messages.', + ], + 'URL generation' => [ + ['domain' => 'example.com', 'path' => 'api/users', 'id' => 123], + 'https://{{domain}}/{{path}}/{{id}}', + 'https://example.com/api/users/123', + ], + 'SQL-like template' => [ + ['table' => 'users', 'field' => 'email', 'value' => 'test@example.com'], + 'SELECT * FROM {{table}} WHERE {{field}} = {{value}}', + 'SELECT * FROM users WHERE email = test@example.com', + ], + ]; + } + + #[Test] + public function itShouldAcceptEmptyParametersArray(): void + { + $formatter = new PlaceholderFormatter([]); + $actual = $formatter->format('Hello World'); + + self::assertSame('Hello World', $actual); + } + + /** + * @param array $constructorParameters + * @param array $additionalParameters + */ + #[Test] + #[DataProvider('providerForFormatWith')] + public function itShouldFormatWithAdditionalParameters( + array $constructorParameters, + array $additionalParameters, + string $template, + string $expected, + ): void { + $formatter = new PlaceholderFormatter($constructorParameters); + $actual = $formatter->formatWith($template, $additionalParameters); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: array, 2: string, 3: string}> */ + public static function providerForFormatWith(): array + { + return [ + 'additional parameters only' => [ + [], + ['name' => 'John'], + 'Hello {{name}}!', + 'Hello John!', + ], + 'constructor parameters only' => [ + ['name' => 'John'], + [], + 'Hello {{name}}!', + 'Hello John!', + ], + 'merged parameters without overlap' => [ + ['name' => 'John'], + ['age' => 30], + 'Hello {{name}}, you are {{age}} years old.', + 'Hello John, you are 30 years old.', + ], + 'constructor parameters take precedence' => [ + ['name' => 'John'], + ['name' => 'Jane'], + 'Hello {{name}}!', + 'Hello John!', + ], + 'mixed precedence with multiple keys' => [ + ['name' => 'John', 'city' => 'New York'], + ['name' => 'Jane', 'age' => 25, 'city' => 'Boston'], + '{{name}} from {{city}} is {{age}} years old.', + 'John from New York is 25 years old.', + ], + 'additional parameters fill missing values' => [ + ['greeting' => 'Hello'], + ['name' => 'World', 'punctuation' => '!'], + '{{greeting}} {{name}}{{punctuation}}', + 'Hello World!', + ], + 'empty additional parameters' => [ + ['name' => 'John'], + [], + 'Hello {{name}}!', + 'Hello John!', + ], + 'both empty parameters' => [ + [], + [], + 'Hello {{name}}!', + 'Hello {{name}}!', + ], + 'additional null value does not override' => [ + ['name' => 'John'], + ['name' => null], + 'Hello {{name}}!', + 'Hello John!', + ], + 'type conversion in additional parameters' => [ + [], + ['count' => 42, 'active' => true], + 'Count: {{count}}, Active: {{active}}', + 'Count: 42, Active: `true`', + ], + ]; + } + + #[Test] + public function itShouldNotModifyOriginalFormatterBehavior(): void + { + $formatter = new PlaceholderFormatter(['name' => 'John']); + + // Call formatWith first + $withResult = $formatter->formatWith('Hello {{name}} and {{other}}!', ['other' => 'World']); + + // Then call format - should still work with original parameters only + $formatResult = $formatter->format('Hello {{name}} and {{other}}!'); + + self::assertSame('Hello John and World!', $withResult); + self::assertSame('Hello John and {{other}}!', $formatResult); + } + + /** @param array $parameters */ + #[Test] + #[DataProvider('providerForComplexScenarios')] + public function itShouldHandleComplexScenarios(array $parameters, string $template, string $expected): void + { + $formatter = new PlaceholderFormatter($parameters); + $actual = $formatter->format($template); + + self::assertSame($expected, $actual); + } + + /** @return array, 1: string, 2: string}> */ + public static function providerForComplexScenarios(): array + { + return [ + 'many placeholders' => [ + ['a' => '1', 'b' => '2', 'c' => '3', 'd' => '4', 'e' => '5'], + '{{a}}-{{b}}-{{c}}-{{d}}-{{e}}', + '1-2-3-4-5', + ], + 'long template' => [ + ['name' => 'Bob'], + 'Hello {{name}}, welcome to our service. We are glad to have you, {{name}}!', + 'Hello Bob, welcome to our service. We are glad to have you, Bob!', + ], + 'nested-looking placeholders' => [ + ['outer' => 'value'], + '{{outer}}', + 'value', + ], + 'adjacent placeholders without separator' => [ + ['first' => 'Hello', 'second' => 'World'], + '{{first}}{{second}}', + 'HelloWorld', + ], + ]; + } +}