Skip to content

Commit 2cae7ba

Browse files
committed
Add QuoteModifier to support flexible string quoting
I’ve ported the `QuoteModifier` from Respect\Validation, but I’ve taken a different approach for this implementation. While the original version relied on and implementation of `Quoter` (from Respect\Stringifier) dependency, I decided to remove it for this context. The `Quoter` interface system was designed to handle complex stringification levels and deep nesting—functionality that is simply overkill for this modifier. By stripping that dependency away, we’ve eliminated unnecessary complexity. A major benefit of this simplification is increased flexibility. Instead of being locked into a predefined quoting strategy, users can now define their preferred quoting character (such as ', ", or backticks) directly in the constructor. Assisted-by: OpenCode (GLM-4.6) Assisted-by: Gemini 3 (Thinking)
1 parent 31328e7 commit 2cae7ba

6 files changed

Lines changed: 235 additions & 1 deletion

File tree

docs/modifiers/Modifiers.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ If no modifier is provided, the formatter uses `StringifyModifier` by default.
5050

5151
- **[AutoQuoteModifier](AutoQuoteModifier.md)** - Quotes string values by default, `|raw` bypasses quoting
5252
- **[ListModifier](ListModifier.md)** - Formats arrays as human-readable lists with conjunctions
53+
- **[QuoteModifier](QuoteModifier.md)** - Quotes string values using a stringifier quoter
5354
- **[StringifyModifier](StringifyModifier.md)** - Converts values to strings (default)
5455

5556
## Creating Custom Modifiers

docs/modifiers/QuoteModifier.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# QuoteModifier
2+
3+
The `|quote` modifier wraps string values with a configurable quote character (default: backtick `` ` ``).
4+
5+
## Behavior
6+
7+
- Strings are wrapped with the quote character and internal occurrences are escaped
8+
- Non-string values delegate to the next modifier
9+
10+
## Usage
11+
12+
```php
13+
use Respect\StringFormatter\PlaceholderFormatter;
14+
15+
$formatter = new PlaceholderFormatter([
16+
'name' => 'John',
17+
'text' => 'Say `hello`',
18+
'count' => 42,
19+
]);
20+
21+
echo $formatter->format('User: {{name|quote}}');
22+
// Output: User: `John`
23+
24+
echo $formatter->format('{{text|quote}}');
25+
// Output: `Say \`hello\``
26+
27+
echo $formatter->format('{{count|quote}}');
28+
// Output: 42 (delegated to next modifier)
29+
```
30+
31+
## Custom Quote Character
32+
33+
```php
34+
use Respect\StringFormatter\PlaceholderFormatter;
35+
use Respect\StringFormatter\Modifiers\QuoteModifier;
36+
use Respect\StringFormatter\Modifiers\StringifyModifier;
37+
38+
$formatter = new PlaceholderFormatter(
39+
['name' => 'John'],
40+
new QuoteModifier(new StringifyModifier(), "'"),
41+
);
42+
43+
echo $formatter->format('{{name|quote}}');
44+
// Output: 'John'
45+
```
46+
47+
## Examples
48+
49+
| Parameters | Template | Output |
50+
| -------------------- | ----------------- | ------------ |
51+
| `['name' => 'John']` | `{{name\|quote}}` | `` `John` `` |
52+
| `['t' => 'a`b']` | `{{t\|quote}}` | `` `a\`b` `` |
53+
| `['n' => 42]` | `{{n\|quote}}` | `42` |

src/Modifiers/QuoteModifier.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Modifiers;
6+
7+
use Respect\StringFormatter\Modifier;
8+
9+
use function addcslashes;
10+
use function is_scalar;
11+
use function sprintf;
12+
13+
final readonly class QuoteModifier implements Modifier
14+
{
15+
public function __construct(
16+
private Modifier $nextModifier,
17+
private string $quote = '`',
18+
) {
19+
}
20+
21+
public function modify(mixed $value, string|null $pipe): string
22+
{
23+
if ($pipe !== 'quote') {
24+
return $this->nextModifier->modify($value, $pipe);
25+
}
26+
27+
if (!is_scalar($value)) {
28+
return $this->nextModifier->modify($value, null);
29+
}
30+
31+
return sprintf('%s%s%s', $this->quote, addcslashes((string) $value, $this->quote), $this->quote);
32+
}
33+
}

src/PlaceholderFormatter.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Respect\StringFormatter;
66

77
use Respect\StringFormatter\Modifiers\ListModifier;
8+
use Respect\StringFormatter\Modifiers\QuoteModifier;
89
use Respect\StringFormatter\Modifiers\StringifyModifier;
910

1011
use function array_key_exists;
@@ -16,7 +17,7 @@
1617
/** @param array<string, mixed> $parameters */
1718
public function __construct(
1819
private array $parameters,
19-
private Modifier $modifier = new ListModifier(new StringifyModifier()),
20+
private Modifier $modifier = new QuoteModifier(new ListModifier(new StringifyModifier())),
2021
) {
2122
}
2223

tests/Helper/TestingQuoter.php

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Helper;
6+
7+
use Respect\Stringifier\Quoter;
8+
9+
use function uniqid;
10+
11+
final class TestingQuoter implements Quoter
12+
{
13+
private string $result;
14+
15+
public function __construct(string|null $result = null)
16+
{
17+
$this->result = $result ?? uniqid();
18+
}
19+
20+
public function quote(string $string, int $depth): string
21+
{
22+
return $this->result;
23+
}
24+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Respect\StringFormatter\Test\Unit\Modifiers;
6+
7+
use PHPUnit\Framework\Attributes\CoversClass;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\Attributes\Test;
10+
use PHPUnit\Framework\TestCase;
11+
use Respect\StringFormatter\Modifiers\QuoteModifier;
12+
use Respect\StringFormatter\Test\Helper\TestingModifier;
13+
14+
#[CoversClass(QuoteModifier::class)]
15+
final class QuoteModifierTest extends TestCase
16+
{
17+
#[Test]
18+
#[DataProvider('providerForPipeNotQuoteCases')]
19+
public function itShouldDelegateToNextModifierWhenPipeIsNotQuote(mixed $value, string|null $pipe): void
20+
{
21+
$nextModifier = new TestingModifier();
22+
$modifier = new QuoteModifier($nextModifier);
23+
$expected = $nextModifier->modify($value, $pipe);
24+
25+
$actual = $modifier->modify($value, $pipe);
26+
27+
self::assertSame($expected, $actual);
28+
}
29+
30+
/** @return array<string, array{0: mixed, 1: string|null}> */
31+
public static function providerForPipeNotQuoteCases(): array
32+
{
33+
return [
34+
'non-string pipe value' => ['some string', 'notQuote'],
35+
'null pipe value' => ['some string', null],
36+
];
37+
}
38+
39+
#[Test]
40+
#[DataProvider('providerForNonScalarWithQuotePipe')]
41+
public function itShouldDelegateToNextModifierWhenValueIsNotScalarAndPipeIsQuote(mixed $value): void
42+
{
43+
$nextModifier = new TestingModifier();
44+
$modifier = new QuoteModifier($nextModifier);
45+
// Non-scalar values with 'quote' pipe should delegate with null pipe
46+
$expected = $nextModifier->modify($value, null);
47+
48+
$actual = $modifier->modify($value, 'quote');
49+
50+
self::assertSame($expected, $actual);
51+
}
52+
53+
/** @return array<string, array{0: mixed}> */
54+
public static function providerForNonScalarWithQuotePipe(): array
55+
{
56+
return [
57+
'array value with quote pipe' => [['not', 'a', 'string']],
58+
'null value with quote pipe' => [null],
59+
'object value with quote pipe' => [(object) ['key' => 'value']],
60+
];
61+
}
62+
63+
#[Test]
64+
#[DataProvider('providerForScalarQuotingCases')]
65+
public function itShouldQuoteScalarValuesWithQuotePipe(
66+
mixed $value,
67+
string $expected,
68+
): void {
69+
$modifier = new QuoteModifier(new TestingModifier());
70+
71+
$actual = $modifier->modify($value, 'quote');
72+
73+
self::assertSame($expected, $actual);
74+
}
75+
76+
/** @return array<string, array{0: mixed, 1: string}> */
77+
public static function providerForScalarQuotingCases(): array
78+
{
79+
return [
80+
'integer value' => [42, '`42`'],
81+
'float value' => [3.14, '`3.14`'],
82+
'boolean true value' => [true, '`1`'],
83+
'boolean false value' => [false, '``'],
84+
];
85+
}
86+
87+
#[Test]
88+
#[DataProvider('providerForStringQuoting')]
89+
public function itShouldQuoteStringsWithVariousContent(string $input, string $expected, string $quote = '`'): void
90+
{
91+
$modifier = new QuoteModifier(new TestingModifier(), $quote);
92+
93+
$actual = $modifier->modify($input, 'quote');
94+
95+
self::assertSame($expected, $actual);
96+
}
97+
98+
/** @return array<string, array{0: string, 1: string, 2: string}> */
99+
public static function providerForStringQuoting(): array
100+
{
101+
return [
102+
'simple string' => ['hello', '`hello`', '`'],
103+
'empty string' => ['', '``', '`'],
104+
'string with backticks' => ['he `hello` there', '`he \\`hello\\` there`', '`'],
105+
'string with backslashes' => ['path\\to\\file', '`path\\to\\file`', '`'],
106+
'string with special characters' => ['!@#$%^&*()', '`!@#$%^&*()`', '`'],
107+
'string with newlines' => ["line1\nline2", "`line1\nline2`", '`'],
108+
'unicode characters' => ['héllo 🌍', '`héllo 🌍`', '`'],
109+
'emoji string' => ['😀🎉', '`😀🎉`', '`'],
110+
'mixed language string' => ['Hello 世界', '`Hello 世界`', '`'],
111+
'html entities' => ['&lt;div&gt;', '`&lt;div&gt;`', '`'],
112+
'url string' => ['https://example.com', '`https://example.com`', '`'],
113+
'email string' => ['user@example.com', '`user@example.com`', '`'],
114+
'single quote string' => ["don't", "`don't`", '`'],
115+
'string with single quotes' => ["he's 'great'", "`he's 'great'`", '`'],
116+
'string with mixed quotes' => ['say "hello" and \'goodbye\'', "`say \"hello\" and 'goodbye'`", '`'],
117+
'test with single quotes' => ["can't stop", '"can\'t stop"', '"'],
118+
'test with double quotes' => ['hello "world"', '"hello \"world\""', '"'],
119+
'test with both quotes' => ['hello "world" and \'test\'', '"hello \"world\" and \'test\'"', '"'],
120+
];
121+
}
122+
}

0 commit comments

Comments
 (0)