Skip to content

Commit

Permalink
feature #435 [PHP 8.3] Polyfill mb_str_pad() (IonBazan)
Browse files Browse the repository at this point in the history
This PR was squashed before being merged into the 1.28-dev branch.

Discussion
----------

[PHP 8.3] Polyfill mb_str_pad()

Polyfills the `mb_str_pad()` function added in PHP 8.3: https://wiki.php.net/rfc/mb_str_pad

Test cases were taken from the RFC implementation and adapted to PHPUnit.

Commits
-------

7ccd416 [PHP 8.3] Polyfill mb_str_pad()
  • Loading branch information
nicolas-grekas committed Jul 28, 2023
2 parents 99e610e + 7ccd416 commit c38c303
Show file tree
Hide file tree
Showing 9 changed files with 302 additions and 3 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Polyfills are provided for:
- the `Random\Engine\Secure` class introduced in PHP 8.2 (check [arokettu/random-polyfill](https://packagist.org/packages/arokettu/random-polyfill) for more engines);
- the `json_validate` function introduced in PHP 8.3;
- the `Override` attribute introduced in PHP 8.3;
- the `mb_str_pad` function introduced in PHP 8.3;

It is strongly recommended to upgrade your PHP version and/or install the missing
extensions whenever possible. This polyfill should be used only when there is no
Expand Down
44 changes: 44 additions & 0 deletions src/Mbstring/Mbstring.php
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,50 @@ public static function mb_ord($s, $encoding = null)
return $code;
}

public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, string $encoding = null): string
{
if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) {
throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH');
}

if (null === $encoding) {
$encoding = self::mb_internal_encoding();
}

try {
$validEncoding = @self::mb_check_encoding('', $encoding);
} catch (\ValueError $e) {
throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding));
}

// BC for PHP 7.3 and lower
if (!$validEncoding) {
throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding));
}

if (self::mb_strlen($pad_string, $encoding) <= 0) {
throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string');
}

$paddingRequired = $length - self::mb_strlen($string, $encoding);

if ($paddingRequired < 1) {
return $string;
}

switch ($pad_type) {
case \STR_PAD_LEFT:
return self::mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding).$string;
case \STR_PAD_RIGHT:
return $string.self::mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding);
default:
$leftPaddingLength = floor($paddingRequired / 2);
$rightPaddingLength = $paddingRequired - $leftPaddingLength;

return self::mb_substr(str_repeat($pad_string, $leftPaddingLength), 0, $leftPaddingLength, $encoding).$string.self::mb_substr(str_repeat($pad_string, $rightPaddingLength), 0, $rightPaddingLength, $encoding);
}
}

private static function getSubpart($pos, $part, $haystack, $encoding)
{
if (false === $pos) {
Expand Down
4 changes: 4 additions & 0 deletions src/Mbstring/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ function mb_scrub($string, $encoding = null) { $encoding = null === $encoding ?
function mb_str_split($string, $length = 1, $encoding = null) { return p\Mbstring::mb_str_split($string, $length, $encoding); }
}

if (!function_exists('mb_str_pad')) {
function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Mbstring::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); }
}

if (extension_loaded('mbstring')) {
return;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Mbstring/bootstrap80.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,10 @@ function mb_scrub(?string $string, ?string $encoding = null): string { $encoding
function mb_str_split(?string $string, ?int $length = 1, ?string $encoding = null): array { return p\Mbstring::mb_str_split((string) $string, (int) $length, $encoding); }
}

if (!function_exists('mb_str_pad')) {
function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Mbstring::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); }
}

if (extension_loaded('mbstring')) {
return;
}
Expand Down
44 changes: 44 additions & 0 deletions src/Php83/Php83.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,48 @@ public static function json_validate(string $json, int $depth = 512, int $flags

return \JSON_ERROR_NONE === json_last_error();
}

public static function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = \STR_PAD_RIGHT, string $encoding = null): string
{
if (!\in_array($pad_type, [\STR_PAD_RIGHT, \STR_PAD_LEFT, \STR_PAD_BOTH], true)) {
throw new \ValueError('mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH');
}

if (null === $encoding) {
$encoding = mb_internal_encoding();
}

try {
$validEncoding = @mb_check_encoding('', $encoding);
} catch (\ValueError $e) {
throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding));
}

// BC for PHP 7.3 and lower
if (!$validEncoding) {
throw new \ValueError(sprintf('mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "%s" given', $encoding));
}

if (mb_strlen($pad_string, $encoding) <= 0) {
throw new \ValueError('mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string');
}

$paddingRequired = $length - mb_strlen($string, $encoding);

if ($paddingRequired < 1) {
return $string;
}

switch ($pad_type) {
case \STR_PAD_LEFT:
return mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding).$string;
case \STR_PAD_RIGHT:
return $string.mb_substr(str_repeat($pad_string, $paddingRequired), 0, $paddingRequired, $encoding);
default:
$leftPaddingLength = floor($paddingRequired / 2);
$rightPaddingLength = $paddingRequired - $leftPaddingLength;

return mb_substr(str_repeat($pad_string, $leftPaddingLength), 0, $leftPaddingLength, $encoding).$string.mb_substr(str_repeat($pad_string, $rightPaddingLength), 0, $rightPaddingLength, $encoding);
}
}
}
1 change: 1 addition & 0 deletions src/Php83/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ This component provides features added to PHP 8.3 core:

- [`json_validate`](https://wiki.php.net/rfc/json_validate)
- [`Override`](https://wiki.php.net/rfc/marking_overriden_methods)
- [`mb_str_pad`](https://wiki.php.net/rfc/mb_str_pad)

More information can be found in the
[main Polyfill README](https://github.com/symfony/polyfill/blob/main/README.md).
Expand Down
4 changes: 4 additions & 0 deletions src/Php83/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@
if (!function_exists('json_validate')) {
function json_validate(string $json, int $depth = 512, int $flags = 0): bool { return p\Php83::json_validate($json, $depth, $flags); }
}

if (!function_exists('mb_str_pad') && function_exists('mb_substr')) {
function mb_str_pad(string $string, int $length, string $pad_string = ' ', int $pad_type = STR_PAD_RIGHT, ?string $encoding = null): string { return p\Php83::mb_str_pad($string, $length, $pad_string, $pad_type, $encoding); }
}
100 changes: 100 additions & 0 deletions tests/Mbstring/MbstringTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -627,4 +627,104 @@ public function testDecodeMimeheader()
$this->assertSame(sprintf('Test: %s', base64_decode('7/Du4uXw6uA=')), mb_decode_mimeheader('Test: =?windows-1251?B?7/Du4uXw6uA=?='));
$this->assertTrue(mb_internal_encoding('utf8'));
}

/**
* @covers \Symfony\Polyfill\Mbstring\Mbstring::mb_str_pad
*
* @dataProvider paddingStringProvider
* @dataProvider paddingEmojiProvider
* @dataProvider paddingEncodingProvider
*/
public function testMbStrPad(string $expectedResult, string $string, int $length, string $padString, int $padType, string $encoding = null): void
{
if ('UTF-32' === $encoding && \PHP_VERSION_ID < 73000) {
$this->markTestSkipped('PHP < 7.3 doesn\'t handle UTF-32 encoding properly');
}

$this->assertSame($expectedResult, mb_convert_encoding(mb_str_pad($string, $length, $padString, $padType, $encoding), 'UTF-8', $encoding ?? mb_internal_encoding()));
}

/**
* @covers \Symfony\Polyfill\Mbstring\Mbstring::mb_str_pad
*
* @dataProvider mbStrPadInvalidArgumentsProvider
*/
public function testMbStrPadInvalidArguments(string $expectedError, string $string, int $length, string $padString, int $padType, string $encoding = null): void
{
$this->expectException(\ValueError::class);
$this->expectErrorMessage($expectedError);

mb_str_pad($string, $length, $padString, $padType, $encoding);
}

public static function paddingStringProvider(): iterable
{
// Simple ASCII strings
yield ['+Hello+', 'Hello', 7, '+-', \STR_PAD_BOTH];
yield ['+-World+-+', 'World', 10, '+-', \STR_PAD_BOTH];
yield ['+-Hello', 'Hello', 7, '+-', \STR_PAD_LEFT];
yield ['+-+-+World', 'World', 10, '+-', \STR_PAD_LEFT];
yield ['Hello+-', 'Hello', 7, '+-', \STR_PAD_RIGHT];
yield ['World+-+-+', 'World', 10, '+-', \STR_PAD_RIGHT];
// Edge cases pad length
yield ['▶▶', '▶▶', 2, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', 1, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', 0, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', -1, ' ', \STR_PAD_BOTH];
// Empty input string
yield [' ', '', 2, ' ', \STR_PAD_BOTH];
yield [' ', '', 1, ' ', \STR_PAD_BOTH];
yield ['', '', 0, ' ', \STR_PAD_BOTH];
yield ['', '', -1, ' ', \STR_PAD_BOTH];
// Default argument
yield ['▶▶ ', '▶▶', 6, ' ', \STR_PAD_RIGHT];
yield [' ▶▶', '▶▶', 6, ' ', \STR_PAD_LEFT];
yield [' ▶▶ ', '▶▶', 6, ' ', \STR_PAD_BOTH];
}

public static function paddingEmojiProvider(): iterable
{
// UTF-8 Emojis
yield ['▶▶❤❓❇❤', '▶▶', 6, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓❇❤▶▶', '▶▶', 6, '❤❓❇', \STR_PAD_LEFT];
yield ['❤❓▶▶❤❓', '▶▶', 6, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤❓❇', '▶▶', 5, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓❇▶▶', '▶▶', 5, '❤❓❇', \STR_PAD_LEFT];
yield ['❤▶▶❤❓', '▶▶', 5, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤❓', '▶▶', 4, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓▶▶', '▶▶', 4, '❤❓❇', \STR_PAD_LEFT];
yield ['❤▶▶❤', '▶▶', 4, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤▶▶', '▶▶', 3, '❤❓❇', \STR_PAD_LEFT];
yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_BOTH];

for ($i = 2; $i >= 0; --$i) {
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_RIGHT];
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_LEFT];
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_BOTH];
}
}

public static function paddingEncodingProvider(): iterable
{
$string = 'Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь';

foreach (['UTF-8', 'UTF-32', 'UTF-7'] as $encoding) {
$input = mb_convert_encoding($string, $encoding, 'UTF-8');
$padStr = mb_convert_encoding('▶▶', $encoding, 'UTF-8');

yield ['Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶▶', $input, 44, $padStr, \STR_PAD_RIGHT, $encoding];
yield ['▶▶▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь', $input, 44, $padStr, \STR_PAD_LEFT, $encoding];
yield ['▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶', $input, 44, $padStr, \STR_PAD_BOTH, $encoding];
}
}

public static function mbStrPadInvalidArgumentsProvider(): iterable
{
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_RIGHT];
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_LEFT];
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_BOTH];
yield ['mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', '▶▶', 6, ' ', 123456];
yield ['mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "unexisting" given', '▶▶', 6, ' ', \STR_PAD_BOTH, 'unexisting'];
}
}
103 changes: 100 additions & 3 deletions tests/Php83/Php83Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ class Php83Test extends TestCase
{
/**
* @covers \Symfony\Polyfill\Php83\Php83::json_validate
*
* @dataProvider jsonDataProvider
*/
public function testJsonValidate(bool $valid, string $json, string $errorMessage = 'No error', int $depth = 512, int $options = 0)
Expand All @@ -25,6 +26,102 @@ public function testJsonValidate(bool $valid, string $json, string $errorMessage
$this->assertSame($errorMessage, json_last_error_msg());
}

/**
* @covers \Symfony\Polyfill\Php83\Php83::mb_str_pad
*
* @dataProvider paddingStringProvider
* @dataProvider paddingEmojiProvider
* @dataProvider paddingEncodingProvider
*/
public function testMbStrPad(string $expectedResult, string $string, int $length, string $padString, int $padType, string $encoding = null): void
{
$this->assertSame($expectedResult, mb_convert_encoding(mb_str_pad($string, $length, $padString, $padType, $encoding), 'UTF-8', $encoding ?? mb_internal_encoding()));
}

/**
* @covers \Symfony\Polyfill\Php83\Php83::mb_str_pad
*
* @dataProvider mbStrPadInvalidArgumentsProvider
*/
public function testMbStrPadInvalidArguments(string $expectedError, string $string, int $length, string $padString, int $padType, string $encoding = null): void
{
$this->expectException(\ValueError::class);
$this->expectErrorMessage($expectedError);

mb_str_pad($string, $length, $padString, $padType, $encoding);
}

public static function paddingStringProvider(): iterable
{
// Simple ASCII strings
yield ['+Hello+', 'Hello', 7, '+-', \STR_PAD_BOTH];
yield ['+-World+-+', 'World', 10, '+-', \STR_PAD_BOTH];
yield ['+-Hello', 'Hello', 7, '+-', \STR_PAD_LEFT];
yield ['+-+-+World', 'World', 10, '+-', \STR_PAD_LEFT];
yield ['Hello+-', 'Hello', 7, '+-', \STR_PAD_RIGHT];
yield ['World+-+-+', 'World', 10, '+-', \STR_PAD_RIGHT];
// Edge cases pad length
yield ['▶▶', '▶▶', 2, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', 1, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', 0, ' ', \STR_PAD_BOTH];
yield ['▶▶', '▶▶', -1, ' ', \STR_PAD_BOTH];
// Empty input string
yield [' ', '', 2, ' ', \STR_PAD_BOTH];
yield [' ', '', 1, ' ', \STR_PAD_BOTH];
yield ['', '', 0, ' ', \STR_PAD_BOTH];
yield ['', '', -1, ' ', \STR_PAD_BOTH];
// Default argument
yield ['▶▶ ', '▶▶', 6, ' ', \STR_PAD_RIGHT];
yield [' ▶▶', '▶▶', 6, ' ', \STR_PAD_LEFT];
yield [' ▶▶ ', '▶▶', 6, ' ', \STR_PAD_BOTH];
}

public static function paddingEmojiProvider(): iterable
{
// UTF-8 Emojis
yield ['▶▶❤❓❇❤', '▶▶', 6, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓❇❤▶▶', '▶▶', 6, '❤❓❇', \STR_PAD_LEFT];
yield ['❤❓▶▶❤❓', '▶▶', 6, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤❓❇', '▶▶', 5, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓❇▶▶', '▶▶', 5, '❤❓❇', \STR_PAD_LEFT];
yield ['❤▶▶❤❓', '▶▶', 5, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤❓', '▶▶', 4, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤❓▶▶', '▶▶', 4, '❤❓❇', \STR_PAD_LEFT];
yield ['❤▶▶❤', '▶▶', 4, '❤❓❇', \STR_PAD_BOTH];
yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_RIGHT];
yield ['❤▶▶', '▶▶', 3, '❤❓❇', \STR_PAD_LEFT];
yield ['▶▶❤', '▶▶', 3, '❤❓❇', \STR_PAD_BOTH];

for ($i = 2; $i >= 0; --$i) {
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_RIGHT];
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_LEFT];
yield ['▶▶', '▶▶', $i, '❤❓❇', \STR_PAD_BOTH];
}
}

public static function paddingEncodingProvider(): iterable
{
$string = 'Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь';

foreach (['UTF-8', 'UTF-32', 'UTF-7'] as $encoding) {
$input = mb_convert_encoding($string, $encoding, 'UTF-8');
$padStr = mb_convert_encoding('▶▶', $encoding, 'UTF-8');

yield ['Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶▶', $input, 44, $padStr, \STR_PAD_RIGHT, $encoding];
yield ['▶▶▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь', $input, 44, $padStr, \STR_PAD_LEFT, $encoding];
yield ['▶Σὲ γνωρίζω ἀπὸ τὴν κόψη Зарегистрируйтесь▶▶', $input, 44, $padStr, \STR_PAD_BOTH, $encoding];
}
}

public static function mbStrPadInvalidArgumentsProvider(): iterable
{
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_RIGHT];
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_LEFT];
yield ['mb_str_pad(): Argument #3 ($pad_string) must be a non-empty string', '▶▶', 6, '', \STR_PAD_BOTH];
yield ['mb_str_pad(): Argument #4 ($pad_type) must be STR_PAD_LEFT, STR_PAD_RIGHT, or STR_PAD_BOTH', '▶▶', 6, ' ', 123456];
yield ['mb_str_pad(): Argument #5 ($encoding) must be a valid encoding, "unexisting" given', '▶▶', 6, ' ', \STR_PAD_BOTH, 'unexisting'];
}

/**
* @return iterable<array{0: bool, 1: string, 2?: string, 3?: int, 4?: int}>
*/
Expand Down Expand Up @@ -59,9 +156,9 @@ public static function jsonDataProvider(): iterable
/**
* @covers \Symfony\Polyfill\Php83\Php83::json_validate
*
* @dataProvider invalidOptionsProvider
* @dataProvider jsonInvalidOptionsProvider
*/
public function testInvalidOptionsProvided(int $depth, int $flags, string $expectedError)
public function testJsonValidateInvalidOptionsProvided(int $depth, int $flags, string $expectedError)
{
$this->expectException(\ValueError::class);
$this->expectErrorMessage($expectedError);
Expand All @@ -71,7 +168,7 @@ public function testInvalidOptionsProvided(int $depth, int $flags, string $expec
/**
* @return iterable<array{0: int, 1: int, 2: string}>
*/
public static function invalidOptionsProvider(): iterable
public static function jsonInvalidOptionsProvider(): iterable
{
yield [0, 0, 'json_validate(): Argument #2 ($depth) must be greater than 0'];
if (\PHP_INT_MAX > 2147483647) {
Expand Down

0 comments on commit c38c303

Please sign in to comment.