├── .phpunit-watcher.yml ├── .styleci.yml ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── UPGRADE.md ├── composer-require-checker.json ├── composer.json ├── infection.json.dist ├── psalm.xml ├── rector.php └── src ├── AbstractCombinedRegexp.php ├── CombinedRegexp.php ├── Inflector.php ├── MemoizedCombinedRegexp.php ├── NumericHelper.php ├── StringHelper.php └── WildcardPattern.php /.phpunit-watcher.yml: -------------------------------------------------------------------------------- 1 | watch: 2 | directories: 3 | - src 4 | - tests 5 | fileMask: '*.php' 6 | notifications: 7 | passingTests: false 8 | failingTests: false 9 | phpunit: 10 | binaryPath: vendor/bin/phpunit 11 | timeout: 180 12 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: psr12 2 | risky: true 3 | 4 | version: 8.1 5 | 6 | finder: 7 | exclude: 8 | - docs 9 | - vendor 10 | 11 | enabled: 12 | - alpha_ordered_traits 13 | - array_indentation 14 | - array_push 15 | - combine_consecutive_issets 16 | - combine_consecutive_unsets 17 | - combine_nested_dirname 18 | - declare_strict_types 19 | - dir_constant 20 | - fully_qualified_strict_types 21 | - function_to_constant 22 | - hash_to_slash_comment 23 | - is_null 24 | - logical_operators 25 | - magic_constant_casing 26 | - magic_method_casing 27 | - method_separation 28 | - modernize_types_casting 29 | - native_function_casing 30 | - native_function_type_declaration_casing 31 | - no_alias_functions 32 | - no_empty_comment 33 | - no_empty_phpdoc 34 | - no_empty_statement 35 | - no_extra_block_blank_lines 36 | - no_short_bool_cast 37 | - no_superfluous_elseif 38 | - no_unneeded_control_parentheses 39 | - no_unneeded_curly_braces 40 | - no_unneeded_final_method 41 | - no_unset_cast 42 | - no_unused_imports 43 | - no_unused_lambda_imports 44 | - no_useless_else 45 | - no_useless_return 46 | - normalize_index_brace 47 | - php_unit_dedicate_assert 48 | - php_unit_dedicate_assert_internal_type 49 | - php_unit_expectation 50 | - php_unit_mock 51 | - php_unit_mock_short_will_return 52 | - php_unit_namespaced 53 | - php_unit_no_expectation_annotation 54 | - phpdoc_no_empty_return 55 | - phpdoc_no_useless_inheritdoc 56 | - phpdoc_order 57 | - phpdoc_property 58 | - phpdoc_scalar 59 | - phpdoc_singular_inheritdoc 60 | - phpdoc_trim 61 | - phpdoc_trim_consecutive_blank_line_separation 62 | - phpdoc_type_to_var 63 | - phpdoc_types 64 | - phpdoc_types_order 65 | - print_to_echo 66 | - regular_callable_call 67 | - return_assignment 68 | - self_accessor 69 | - self_static_accessor 70 | - set_type_to_cast 71 | - short_array_syntax 72 | - short_list_syntax 73 | - simplified_if_return 74 | - single_quote 75 | - standardize_not_equals 76 | - ternary_to_null_coalescing 77 | - trailing_comma_in_multiline_array 78 | - unalign_double_arrow 79 | - unalign_equals 80 | - empty_loop_body_braces 81 | - integer_literal_case 82 | - union_type_without_spaces 83 | 84 | disabled: 85 | - function_declaration 86 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Yii Strings Change Log 2 | 3 | ## 2.6.1 under development 4 | 5 | - no changes in this release. 6 | 7 | ## 2.6.0 February 09, 2025 8 | 9 | - Chg #140: Bump minimal required PHP version to 8.1 and minor refactoring (@vjik) 10 | - Chg #143: Change PHP constraint in `composer.json` to `~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0` (@vjik) 11 | - Bug #138: Explicitly mark nullable parameters (@ferrumfist) 12 | - Bug #141: `StringHelper::parsePath()` for empty string returns path `['']` instead of `[]` before (@vjik) 13 | - Bug #142: Check string on a valid UTF-8 in `StringHelper` methods: `trim()`, `ltrim()` and `rtrim()` (@vjik) 14 | 15 | ## 2.5.0 January 19, 2025 16 | 17 | - New #137: Add `StringHelper::matchAnyRegex()` method as a facade for `CombinedRegexp` (@vjik) 18 | - Enh #128: Add more specific psalm type for result of `StringHelper::base64UrlEncode()` method (@vjik) 19 | 20 | ## 2.4.0 December 22, 2023 21 | 22 | - New #118: Add `findBetween()`, `findBetweenFirst()` and `findBetweenLast()` methods to `StringHelper` to retrieve 23 | a substring that lies between two strings (@salehhashemi1992) 24 | - Enh #121: Don't use regexp if there is no delimeter in the path in `StringHelper::parsePath()` (@viktorprogger) 25 | 26 | ## 2.3.1 October 30, 2023 27 | 28 | - Enh #117: `WildcardPatters` uses memoization and accelerates ~2 times on repeated calls (@viktorprogger) 29 | 30 | ## 2.3.0 October 23, 2023 31 | 32 | - Enh #114: Add stringable object support to `NumericHelper::normalize()` (@vjik) 33 | 34 | ## 2.2.0 September 20, 2023 35 | 36 | - New #102, #106: Add `CombinedRegexp` class (@xepozz, @vjik) 37 | - New #103: Add `MemoizedCombinedRegexp` decorator that caches results of `CombinedRegexp` (@xepozz) 38 | - New #104: Add methods `StringHelper::trim()`, `StringHelper::ltrim()`, `StringHelper::rtrim()` (@olegbaturin) 39 | - Enh #103: Raise required PHP version to `^8.0` (@xepozz) 40 | - Enh #106: Using fully-qualified function calls to improve performance (@vjik) 41 | - Enh #111: Minor refactoring (@Tigrov) 42 | - Enh #83: Make minor refactoring with Rector help (@vjik) 43 | - Enh #92: Add `$strict` parameter to `Inflector::toSnakeCase()` method (@arogachev) 44 | 45 | ## 2.1.2 July 27, 2023 46 | 47 | - Bug #105: Fix incorrect split UTF-8 strings in `StringHelper::split()` method (@vjik) 48 | 49 | ## 2.1.1 April 28, 2023 50 | 51 | - Enh #85: Improve `StringHelper::parsePath()` method annotation (@vjik) 52 | 53 | ## 2.1.0 August 20, 2022 54 | 55 | - New #75: Add method `Inflector::toSnakeCase()` that convert word to "snake_case" (@soodssr) 56 | - New #81: Add `StringHelper::parsePath()` method (@arogachev, @vjik) 57 | 58 | ## 2.0.0 February 10, 2021 59 | 60 | - Chg #67: Remove `\Yiisoft\Strings\WildcardPattern::withoutEscape()` (@samdark) 61 | - Chg #67: Remove `\Yiisoft\Strings\WildcardPattern::withExactLeadingPeriod()` (@samdark) 62 | - Enh #67: Add `**`, match anything including `/`, to `\Yiisoft\Strings\WildcardPattern`, remove `withExactSlashes()` and `withEnding()` (@samdark) 63 | - Enh #67: Allow specifying delimiters for `*` (@samdark) 64 | - Enh #67: Add `\Yiisoft\Strings\WildcardPattern::isDynamic()` (@samdark) 65 | - Enh #67: Add `\Yiisoft\Strings\WildcardPattern::quote()` (@samdark) 66 | 67 | ## 1.2.0 January 22, 2021 68 | 69 | - Enh #62: Add method `StringHelper::split()` that split a string to array with non-empty lines (@vjik) 70 | - Enh #63: Add method `NumericHelper::isInteger()` that checks whether the given string is an integer number (@vjik) 71 | - Enh #64: Add support of a boolean values to `NumericHelper::normalize()` (@vjik) 72 | 73 | ## 1.1.0 November 13, 2020 74 | 75 | - Enh #52: Allow turning off options in `WildcardPattern` (@vjik) 76 | - Enh #51: Add an option `withEnding()` to `WildcardPattern` for match ending of testing string (@vjik) 77 | - Bug #44: `NumericHelper::toOrdinal()` throws an error for numbers with fractional part (@vjik) 78 | 79 | ## 1.0.1 October 12, 2020 80 | 81 | - Enh #40: Use `str_starts_with()` and `str_ends_with()` if available (@viktorprogger) 82 | - Bug #43: `NumericHelper::normalize()` throws an error for `float` or `int` values in PHP 8 (@vjik) 83 | 84 | ## 1.0.0 August 31, 2020 85 | 86 | - Initial release. 87 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © 2008 by Yii Software (https://www.yiiframework.com/) 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in 12 | the documentation and/or other materials provided with the 13 | distribution. 14 | * Neither the name of Yii Software nor the names of its 15 | contributors may be used to endorse or promote products derived 16 | from this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 19 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 20 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 21 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 22 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 23 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 24 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 25 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 27 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 28 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Yii 4 | 5 |

Yii Strings

6 |
7 |

8 | 9 | [![Latest Stable Version](https://poser.pugx.org/yiisoft/strings/v)](https://packagist.org/packages/yiisoft/strings) 10 | [![Total Downloads](https://poser.pugx.org/yiisoft/strings/downloads)](https://packagist.org/packages/yiisoft/strings) 11 | [![Build status](https://github.com/yiisoft/strings/actions/workflows/build.yml/badge.svg)](https://github.com/yiisoft/strings/actions/workflows/build.yml) 12 | [![Code coverage](https://codecov.io/gh/yiisoft/strings/graph/badge.svg?token=GEPMBAHNCX)](https://codecov.io/gh/yiisoft/strings) 13 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fyiisoft%2Fstrings%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/yiisoft/strings/master) 14 | [![static analysis](https://github.com/yiisoft/strings/workflows/static%20analysis/badge.svg)](https://github.com/yiisoft/strings/actions?query=workflow%3A%22static+analysis%22) 15 | [![type-coverage](https://shepherd.dev/github/yiisoft/strings/coverage.svg)](https://shepherd.dev/github/yiisoft/strings) 16 | 17 | The package provides: 18 | 19 | - `StringHelper` that has static methods to work with strings; 20 | - `NumericHelper` that has static methods to work with numeric strings; 21 | - `Inflector` provides methods such as `toPlural()` or `toSlug()` that derive a new string based on the string given; 22 | - `WildcardPattern` is a shell wildcard pattern to match strings against; 23 | - `CombinedRegexp` is a wrapper that optimizes multiple regular expressions matching and 24 | `MemoizedCombinedRegexp` is a decorator that caches results of `CombinedRegexp` to speed up matching. 25 | 26 | ## Requirements 27 | 28 | - PHP 8.1 or higher. 29 | - `mbstring` PHP extension. 30 | 31 | ## Installation 32 | 33 | The package could be installed with [Composer](https://getcomposer.org): 34 | 35 | ```shell 36 | composer require yiisoft/strings 37 | ``` 38 | 39 | ## StringHelper usage 40 | 41 | String helper methods are static so usage is like the following: 42 | 43 | ```php 44 | echo \Yiisoft\Strings\StringHelper::countWords('Strings are cool!'); // 3 45 | ``` 46 | 47 | Overall the helper has the following method groups. 48 | 49 | ### Bytes 50 | 51 | - byteLength 52 | - byteSubstring 53 | 54 | ### File paths 55 | 56 | - baseName 57 | - directoryName 58 | 59 | ### Substrings 60 | 61 | - substring 62 | - replaceSubstring 63 | - startsWith 64 | - startsWithIgnoringCase 65 | - endsWith 66 | - endsWithIgnoringCase 67 | - findBetween 68 | - findBetweenFirst 69 | - findBetweenLast 70 | 71 | ### Truncation 72 | 73 | - truncateBegin 74 | - truncateMiddle 75 | - truncateEnd 76 | - truncateWords 77 | - trim 78 | - ltrim 79 | - rtrim 80 | 81 | ### Counting 82 | 83 | - length 84 | - countWords 85 | 86 | ### Lowercase and uppercase 87 | 88 | - lowercase 89 | - uppercase 90 | - uppercaseFirstCharacter 91 | - uppercaseFirstCharacterInEachWord 92 | 93 | ### URL friendly base64 94 | 95 | - base64UrlEncode 96 | - base64UrlDecode 97 | 98 | ### Other 99 | 100 | - matchAnyRegex 101 | - parsePath 102 | - split 103 | 104 | ## NumericHelper usage 105 | 106 | Numeric helper methods are static so usage is like the following: 107 | 108 | ```php 109 | echo \Yiisoft\Strings\NumericHelper::toOrdinal(3); // 3rd 110 | ``` 111 | 112 | The following methods are available: 113 | 114 | - toOrdinal 115 | - normalize 116 | - isInteger 117 | 118 | ## Inflector usage 119 | 120 | ```php 121 | echo (new \Yiisoft\Strings\Inflector()) 122 | ->withoutIntl() 123 | ->toSlug('Strings are cool!'); // strings-are-cool 124 | ``` 125 | 126 | Overall the inflector has the following method groups. 127 | 128 | ### Plurals and singulars 129 | 130 | - toPlural 131 | - toSingular 132 | 133 | ### Transliteration 134 | 135 | - toTransliterated 136 | 137 | ### Case conversion 138 | 139 | - pascalCaseToId 140 | - toPascalCase 141 | - toCamelCase 142 | 143 | ### Words and sentences 144 | 145 | - toSentence 146 | - toWords 147 | - toHumanReadable 148 | 149 | ### Classes and database tables 150 | 151 | - classToTable 152 | - tableToClass 153 | 154 | ### URLs 155 | 156 | - toSlug 157 | 158 | ## WildcardPattern usage 159 | 160 | `WildcardPattern` allows a simple POSIX-style string matching. 161 | 162 | ```php 163 | use \Yiisoft\Strings\WildcardPattern; 164 | 165 | $startsWithTest = new WildcardPattern('test*'); 166 | if ($startsWithTest->match('testIfThisIsTrue')) { 167 | echo 'It starts with "test"!'; 168 | } 169 | ``` 170 | 171 | The following characters are special in the pattern: 172 | 173 | - `\` escapes other special characters if usage of escape character is not turned off. 174 | - `*` matches any string, including the empty string, except delimiters (`/` and `\` by default). 175 | - `**` matches any string, including the empty string and delimiters. 176 | - `?` matches any single character. 177 | - `[seq]` matches any character in seq. 178 | - `[a-z]` matches any character from a to z. 179 | - `[!seq]` matches any character not in seq. 180 | - `[[:alnum:]]` matches [POSIX style character classes](https://www.php.net/manual/en/regexp.reference.character-classes.php). 181 | 182 | `ignoreCase()` could be called before doing a `match()` to get a case-insensitive match: 183 | 184 | ```php 185 | use \Yiisoft\Strings\WildcardPattern; 186 | 187 | $startsWithTest = new WildcardPattern('test*'); 188 | if ($startsWithTest 189 | ->ignoreCase() 190 | ->match('tEStIfThisIsTrue')) { 191 | echo 'It starts with "test"!'; 192 | } 193 | ``` 194 | 195 | ## CombinedRegexp usage 196 | 197 | `CombinedRegexp` optimizes matching multiple regular expressions. 198 | 199 | ```php 200 | use \Yiisoft\Strings\CombinedRegexp; 201 | 202 | $patterns = [ 203 | 'first', 204 | 'second', 205 | '^a\d$', 206 | ]; 207 | $regexp = new CombinedRegexp($patterns, 'i'); 208 | $regexp->matches('a5'); // true – matches the third pattern 209 | $regexp->matches('A5'); // true – matches the third pattern because of `i` flag that is applied to all regular expressions 210 | $regexp->getMatchingPattern('a5'); // '^a\d$' – the pattern that matched 211 | $regexp->getMatchingPatternPosition('a5'); // 2 – the index of the pattern in the array 212 | $regexp->getCompiledPattern(); // '~(?|first|second()|^a\d$()())~' 213 | ``` 214 | 215 | ## MemoizedCombinedRegexp usage 216 | 217 | `MemoizedCombinedRegexp` caches results of `CombinedRegexp` in memory. 218 | It is useful when the same incoming string are matching multiple times or different methods of `CombinedRegexp` are called. 219 | 220 | ```php 221 | use \Yiisoft\Strings\CombinedRegexp; 222 | use \Yiisoft\Strings\MemoizedCombinedRegexp; 223 | 224 | $patterns = [ 225 | 'first', 226 | 'second', 227 | '^a\d$', 228 | ]; 229 | $regexp = new MemoizedCombinedRegexp(new CombinedRegexp($patterns, 'i')); 230 | $regexp->matches('a5'); // Fires `preg_match` inside the `CombinedRegexp`. 231 | $regexp->matches('first'); // Fires `preg_match` inside the `CombinedRegexp`. 232 | $regexp->matches('a5'); // Does not fire `preg_match` inside the `CombinedRegexp` because the result is cached. 233 | $regexp->getMatchingPattern('a5'); // The result is cached so no `preg_match` is fired. 234 | $regexp->getMatchingPatternPosition('a5'); // The result is cached so no `preg_match` is fired. 235 | 236 | // The following code fires only once matching mechanism. 237 | if ($regexp->matches('second')) { 238 | echo sprintf( 239 | 'Matched the pattern "%s" which is on the position "%s" in the expressions list.', 240 | $regexp->getMatchingPattern('second'), 241 | $regexp->getMatchingPatternPosition('second'), 242 | ); 243 | } 244 | ``` 245 | 246 | ## Documentation 247 | 248 | - [Internals](docs/internals.md) 249 | 250 | If you need help or have a question, the [Yii Forum](https://forum.yiiframework.com/c/yii-3-0/63) is a good place for that. 251 | You may also check out other [Yii Community Resources](https://www.yiiframework.com/community). 252 | 253 | ## License 254 | 255 | The Yii Strings is free software. It is released under the terms of the BSD License. 256 | Please see [`LICENSE`](./LICENSE.md) for more information. 257 | 258 | Maintained by [Yii Software](https://www.yiiframework.com/). 259 | 260 | ## Support the project 261 | 262 | [![Open Collective](https://img.shields.io/badge/Open%20Collective-sponsor-7eadf1?logo=open%20collective&logoColor=7eadf1&labelColor=555555)](https://opencollective.com/yiisoft) 263 | 264 | ## Follow updates 265 | 266 | [![Official website](https://img.shields.io/badge/Powered_by-Yii_Framework-green.svg?style=flat)](https://www.yiiframework.com/) 267 | [![Twitter](https://img.shields.io/badge/twitter-follow-1DA1F2?logo=twitter&logoColor=1DA1F2&labelColor=555555?style=flat)](https://twitter.com/yiiframework) 268 | [![Telegram](https://img.shields.io/badge/telegram-join-1DA1F2?style=flat&logo=telegram)](https://t.me/yii3en) 269 | [![Facebook](https://img.shields.io/badge/facebook-join-1DA1F2?style=flat&logo=facebook&logoColor=ffffff)](https://www.facebook.com/groups/yiitalk) 270 | [![Slack](https://img.shields.io/badge/slack-join-1DA1F2?style=flat&logo=slack)](https://yiiframework.com/go/slack) 271 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrading Instructions for Yii Strings 2 | 3 | This file contains the upgrade notes. These notes highlight changes that could break your 4 | application when you upgrade the package from one version to another. 5 | 6 | > **Important!** The following upgrading instructions are cumulative. That is, if you want 7 | > to upgrade from version A to version C and there is version B between A and C, you need 8 | > to following the instructions for both A and B. 9 | 10 | ## Upgrade from 1.2.0 11 | 12 | `\Yiisoft\Strings\WildCardPattern` was changed. 13 | 14 | - `\Yiisoft\Strings\WildcardPattern::withExactSlashes()` was removed. `*` now always doesn't match `/`. 15 | - `**` was introduced to match anything including `/`. 16 | - `\Yiisoft\Strings\WildcardPattern::withExactLeadingPeriod()` was removed. There is no replacement for old behavior. 17 | - `\Yiisoft\Strings\WildcardPattern::withEnding()` was removed. 18 | - `\Yiisoft\Strings\WildcardPattern::withoutEscape()` was removed. 19 | 20 | To fix possible issues: 21 | 22 | - Remove `withExactSlashes()` calls. 23 | - Replace `*` with `**` in patterns if you need to match `/` as well. 24 | - If `withEnding()` was used, add `**` to the beginning of the pattern. 25 | - If `withoutEscape()` was used, escape `\` in patterns with another `\`. 26 | Likely `\Yiisoft\Strings\WildcardPattern::quote()` may be of help. 27 | -------------------------------------------------------------------------------- /composer-require-checker.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist": [ 3 | "transliterator_transliterate", 4 | "filter_var", 5 | "FILTER_VALIDATE_INT" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yiisoft/strings", 3 | "type": "library", 4 | "description": "Yii Strings Helper", 5 | "keywords": [ 6 | "yii", 7 | "helper", 8 | "string" 9 | ], 10 | "homepage": "https://www.yiiframework.com/", 11 | "license": "BSD-3-Clause", 12 | "support": { 13 | "issues": "https://github.com/yiisoft/strings/issues?state=open", 14 | "source": "https://github.com/yiisoft/strings", 15 | "forum": "https://www.yiiframework.com/forum/", 16 | "wiki": "https://www.yiiframework.com/wiki/", 17 | "irc": "ircs://irc.libera.chat:6697/yii", 18 | "chat": "https://t.me/yii3en" 19 | }, 20 | "funding": [ 21 | { 22 | "type": "opencollective", 23 | "url": "https://opencollective.com/yiisoft" 24 | }, 25 | { 26 | "type": "github", 27 | "url": "https://github.com/sponsors/yiisoft" 28 | } 29 | ], 30 | "require": { 31 | "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0", 32 | "ext-mbstring": "*" 33 | }, 34 | "require-dev": { 35 | "maglnet/composer-require-checker": "^4.7.1", 36 | "phpunit/phpunit": "^10.5.45", 37 | "rector/rector": "^2.0.8", 38 | "roave/infection-static-analysis-plugin": "^1.35", 39 | "spatie/phpunit-watcher": "^1.24", 40 | "vimeo/psalm": "^5.26.1|^6.4.1" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Yiisoft\\Strings\\": "src" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Yiisoft\\Strings\\Tests\\": "tests" 50 | } 51 | }, 52 | "config": { 53 | "sort-packages": true, 54 | "bump-after-update": "dev", 55 | "allow-plugins": { 56 | "infection/extension-installer": true, 57 | "composer/package-versions-deprecated": true 58 | } 59 | }, 60 | "scripts": { 61 | "test": "phpunit --testdox --no-interaction", 62 | "test-watch": "phpunit-watcher watch" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "directories": [ 4 | "src" 5 | ] 6 | }, 7 | "logs": { 8 | "text": "php:\/\/stderr", 9 | "stryker": { 10 | "report": "master" 11 | } 12 | }, 13 | "mutators": { 14 | "@default": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPaths([ 12 | __DIR__ . '/src', 13 | __DIR__ . '/tests', 14 | ]) 15 | ->withPhpSets(php81: true) 16 | ->withRules([ 17 | InlineConstructorDefaultToPropertyRector::class, 18 | ]) 19 | ->withSkip([ 20 | ClosureToArrowFunctionRector::class, 21 | NullToStrictStringFuncCallArgRector::class, 22 | ]); 23 | -------------------------------------------------------------------------------- /src/AbstractCombinedRegexp.php: -------------------------------------------------------------------------------- 1 | getCompiledPattern(), 70 | $string, 71 | ) 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/CombinedRegexp.php: -------------------------------------------------------------------------------- 1 | patterns = array_values($patterns); 46 | $this->compiledPattern = $this->compilePatterns($this->patterns) . $this->flags; 47 | } 48 | 49 | /** 50 | * @return string The compiled pattern. 51 | */ 52 | public function getCompiledPattern(): string 53 | { 54 | return $this->compiledPattern; 55 | } 56 | 57 | /** 58 | * Returns `true` whether the given string matches any of the patterns, `false` - otherwise. 59 | */ 60 | public function matches(string $string): bool 61 | { 62 | return preg_match($this->compiledPattern, $string) === 1; 63 | } 64 | 65 | /** 66 | * Returns pattern that matches the given string. 67 | * @throws Exception if the string does not match any of the patterns. 68 | */ 69 | public function getMatchingPattern(string $string): string 70 | { 71 | return $this->patterns[$this->getMatchingPatternPosition($string)]; 72 | } 73 | 74 | /** 75 | * Returns position of the pattern that matches the given string. 76 | * @throws Exception if the string does not match any of the patterns. 77 | */ 78 | public function getMatchingPatternPosition(string $string): int 79 | { 80 | $match = preg_match($this->compiledPattern, $string, $matches); 81 | if ($match !== 1) { 82 | $this->throwFailedMatchException($string); 83 | } 84 | 85 | return count($matches) - 1; 86 | } 87 | 88 | /** 89 | * @param string[] $patterns 90 | * 91 | * @psalm-param list $patterns 92 | * @psalm-return non-empty-string 93 | */ 94 | private function compilePatterns(array $patterns): string 95 | { 96 | $quotedPatterns = []; 97 | 98 | /** 99 | * Possible mutant escaping, but it's ok for our case. 100 | * It doesn't matter where to place `()` in the pattern: 101 | * https://regex101.com/r/lE1Q1S/1, https://regex101.com/r/rWg7Fj/1 102 | */ 103 | foreach ($patterns as $i => $pattern) { 104 | $quotedPatterns[] = $pattern . str_repeat('()', $i); 105 | } 106 | 107 | $combinedRegexps = '(?|' . strtr( 108 | implode('|', $quotedPatterns), 109 | [self::REGEXP_DELIMITER => self::QUOTE_REPLACER] 110 | ) . ')'; 111 | 112 | return self::REGEXP_DELIMITER . $combinedRegexps . self::REGEXP_DELIMITER; 113 | } 114 | 115 | public function getPatterns(): array 116 | { 117 | return $this->patterns; 118 | } 119 | 120 | public function getFlags(): string 121 | { 122 | return $this->flags; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/Inflector.php: -------------------------------------------------------------------------------- 1 | 74 | * The keys are the regular expressions and the values are the corresponding replacements. 75 | */ 76 | private array $pluralizeRules = [ 77 | '/([nrlm]ese|deer|fish|sheep|measles|ois|pox|media)$/i' => '\1', 78 | '/^(sea[- ]bass)$/i' => '\1', 79 | '/(m)ove$/i' => '\1oves', 80 | '/(f)oot$/i' => '\1eet', 81 | '/(h)uman$/i' => '\1umans', 82 | '/(s)tatus$/i' => '\1tatuses', 83 | '/(s)taff$/i' => '\1taff', 84 | '/(t)ooth$/i' => '\1eeth', 85 | '/(quiz)$/i' => '\1zes', 86 | '/^(ox)$/i' => '\1\2en', 87 | '/([m|l])ouse$/i' => '\1ice', 88 | '/(matr|vert|ind)(ix|ex)$/i' => '\1ices', 89 | '/(x|ch|ss|sh)$/i' => '\1es', 90 | '/([^aeiouy]|qu)y$/i' => '\1ies', 91 | '/(hive)$/i' => '\1s', 92 | '/(?:([^f])fe|([lr])f)$/i' => '\1\2ves', 93 | '/sis$/i' => 'ses', 94 | '/([ti])um$/i' => '\1a', 95 | '/(p)erson$/i' => '\1eople', 96 | '/(m)an$/i' => '\1en', 97 | '/(c)hild$/i' => '\1hildren', 98 | '/(buffal|tomat|potat|ech|her|vet)o$/i' => '\1oes', 99 | '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|vir)us$/i' => '\1i', 100 | '/us$/i' => 'uses', 101 | '/(alias)$/i' => '\1es', 102 | '/(ax|cris|test)is$/i' => '\1es', 103 | '/(currenc)y$/' => '\1ies', 104 | '/on$/i' => 'a', 105 | '/s$/' => 's', 106 | '/^$/' => '', 107 | '/$/' => 's', 108 | ]; 109 | 110 | /** 111 | * @var string[] The rules for converting a word into its singular form. 112 | * 113 | * @psalm-var non-empty-array 114 | * The keys are the regular expressions and the values are the corresponding replacements. 115 | */ 116 | private array $singularizeRules = [ 117 | '/([nrlm]ese|deer|fish|sheep|measles|ois|pox|media|ss)$/i' => '\1', 118 | '/^(sea[- ]bass)$/i' => '\1', 119 | '/(s)tatuses$/i' => '\1tatus', 120 | '/(f)eet$/i' => '\1oot', 121 | '/(t)eeth$/i' => '\1ooth', 122 | '/^(.*)(menu)s$/i' => '\1\2', 123 | '/(quiz)zes$/i' => '\\1', 124 | '/(matr)ices$/i' => '\1ix', 125 | '/(vert|ind)ices$/i' => '\1ex', 126 | '/^(ox)en/i' => '\1', 127 | '/(alias)(es)*$/i' => '\1', 128 | '/(alumn|bacill|cact|foc|fung|nucle|radi|stimul|syllab|termin|viri?)i$/i' => '\1us', 129 | '/([ftw]ax)es/i' => '\1', 130 | '/(cris|ax|test)es$/i' => '\1is', 131 | '/(shoe|slave)s$/i' => '\1', 132 | '/(o)es$/i' => '\1', 133 | '/ouses$/i' => 'ouse', 134 | '/([^a])uses$/i' => '\1us', 135 | '/([m|l])ice$/i' => '\1ouse', 136 | '/(x|ch|ss|sh)es$/i' => '\1', 137 | '/(m)ovies$/i' => '\1\2ovie', 138 | '/(s)eries$/i' => '\1\2eries', 139 | '/([^aeiouy]|qu)ies$/i' => '\1y', 140 | '/([lr])ves$/i' => '\1f', 141 | '/(tive)s$/i' => '\1', 142 | '/(hive)s$/i' => '\1', 143 | '/(drive)s$/i' => '\1', 144 | '/([^fo])ves$/i' => '\1fe', 145 | '/(^analy)ses$/i' => '\1sis', 146 | '/(analy|diagno|^ba|(p)arenthe|(p)rogno|(s)ynop|(t)he)ses$/i' => '\1\2sis', 147 | '/criteria$/i' => 'criterion', 148 | '/([ti])a$/i' => '\1um', 149 | '/(p)eople$/i' => '\1\2erson', 150 | '/(m)en$/i' => '\1an', 151 | '/(c)hildren$/i' => '\1\2hild', 152 | '/(n)ews$/i' => '\1\2ews', 153 | '/(n)etherlands$/i' => '\1\2etherlands', 154 | '/eaus$/i' => 'eau', 155 | '/(currenc)ies$/i' => '\1y', 156 | '/^(.*us)$/i' => '\\1', 157 | '/s$/i' => '', 158 | ]; 159 | 160 | /** 161 | * @psalm-var array 162 | * 163 | * @var string[] The special rules for converting a word between its plural form and singular form. 164 | * The keys are the special words in singular form, and the values are the corresponding plural form. 165 | */ 166 | private array $specialRules = [ 167 | 'Amoyese' => 'Amoyese', 168 | 'Borghese' => 'Borghese', 169 | 'Congoese' => 'Congoese', 170 | 'Faroese' => 'Faroese', 171 | 'Foochowese' => 'Foochowese', 172 | 'Genevese' => 'Genevese', 173 | 'Genoese' => 'Genoese', 174 | 'Gilbertese' => 'Gilbertese', 175 | 'Hottentotese' => 'Hottentotese', 176 | 'Kiplingese' => 'Kiplingese', 177 | 'Kongoese' => 'Kongoese', 178 | 'Lucchese' => 'Lucchese', 179 | 'Maltese' => 'Maltese', 180 | 'Nankingese' => 'Nankingese', 181 | 'Niasese' => 'Niasese', 182 | 'Pekingese' => 'Pekingese', 183 | 'Piedmontese' => 'Piedmontese', 184 | 'Pistoiese' => 'Pistoiese', 185 | 'Portuguese' => 'Portuguese', 186 | 'Sarawakese' => 'Sarawakese', 187 | 'Shavese' => 'Shavese', 188 | 'Vermontese' => 'Vermontese', 189 | 'Wenchowese' => 'Wenchowese', 190 | 'Yengeese' => 'Yengeese', 191 | 'atlas' => 'atlases', 192 | 'beef' => 'beefs', 193 | 'bison' => 'bison', 194 | 'bream' => 'bream', 195 | 'breeches' => 'breeches', 196 | 'britches' => 'britches', 197 | 'brother' => 'brothers', 198 | 'buffalo' => 'buffalo', 199 | 'cafe' => 'cafes', 200 | 'cantus' => 'cantus', 201 | 'carp' => 'carp', 202 | 'chassis' => 'chassis', 203 | 'child' => 'children', 204 | 'clippers' => 'clippers', 205 | 'cod' => 'cod', 206 | 'coitus' => 'coitus', 207 | 'contretemps' => 'contretemps', 208 | 'cookie' => 'cookies', 209 | 'corps' => 'corps', 210 | 'corpus' => 'corpuses', 211 | 'cow' => 'cows', 212 | 'curve' => 'curves', 213 | 'debris' => 'debris', 214 | 'diabetes' => 'diabetes', 215 | 'djinn' => 'djinn', 216 | 'eland' => 'eland', 217 | 'elk' => 'elk', 218 | 'equipment' => 'equipment', 219 | 'flounder' => 'flounder', 220 | 'foe' => 'foes', 221 | 'gallows' => 'gallows', 222 | 'ganglion' => 'ganglions', 223 | 'genie' => 'genies', 224 | 'genus' => 'genera', 225 | 'graffiti' => 'graffiti', 226 | 'graffito' => 'graffiti', 227 | 'headquarters' => 'headquarters', 228 | 'herpes' => 'herpes', 229 | 'hijinks' => 'hijinks', 230 | 'hoof' => 'hoofs', 231 | 'information' => 'information', 232 | 'innings' => 'innings', 233 | 'jackanapes' => 'jackanapes', 234 | 'loaf' => 'loaves', 235 | 'mackerel' => 'mackerel', 236 | 'man' => 'men', 237 | 'mews' => 'mews', 238 | 'money' => 'monies', 239 | 'mongoose' => 'mongooses', 240 | 'moose' => 'moose', 241 | 'move' => 'moves', 242 | 'mumps' => 'mumps', 243 | 'mythos' => 'mythoi', 244 | 'news' => 'news', 245 | 'nexus' => 'nexus', 246 | 'niche' => 'niches', 247 | 'numen' => 'numina', 248 | 'occiput' => 'occiputs', 249 | 'octopus' => 'octopuses', 250 | 'opus' => 'opuses', 251 | 'ox' => 'oxen', 252 | 'pasta' => 'pasta', 253 | 'penis' => 'penises', 254 | 'pincers' => 'pincers', 255 | 'pliers' => 'pliers', 256 | 'proceedings' => 'proceedings', 257 | 'rabies' => 'rabies', 258 | 'rhinoceros' => 'rhinoceros', 259 | 'rice' => 'rice', 260 | 'salmon' => 'salmon', 261 | 'scissors' => 'scissors', 262 | 'series' => 'series', 263 | 'sex' => 'sexes', 264 | 'shears' => 'shears', 265 | 'siemens' => 'siemens', 266 | 'soliloquy' => 'soliloquies', 267 | 'species' => 'species', 268 | 'swine' => 'swine', 269 | 'testes' => 'testes', 270 | 'testis' => 'testes', 271 | 'trilby' => 'trilbys', 272 | 'trousers' => 'trousers', 273 | 'trout' => 'trout', 274 | 'tuna' => 'tuna', 275 | 'turf' => 'turfs', 276 | 'wave' => 'waves', 277 | 'whiting' => 'whiting', 278 | 'wildebeest' => 'wildebeest', 279 | ]; 280 | 281 | /** 282 | * @var string[] Fallback map for transliteration used by {@see toTransliterated()} when intl isn't available or 283 | * turned off with {@see withoutIntl()}. 284 | */ 285 | private array $transliterationMap = [ 286 | 'À' => 'A', 'Á' => 'A', 'Â' => 'A', 'Ã' => 'A', 'Ä' => 'A', 'Å' => 'A', 'Æ' => 'AE', 'Ç' => 'C', 287 | 'È' => 'E', 'É' => 'E', 'Ê' => 'E', 'Ë' => 'E', 'Ì' => 'I', 'Í' => 'I', 'Î' => 'I', 'Ï' => 'I', 288 | 'Ð' => 'D', 'Ñ' => 'N', 'Ò' => 'O', 'Ó' => 'O', 'Ô' => 'O', 'Õ' => 'O', 'Ö' => 'O', 'Ő' => 'O', 289 | 'Ø' => 'O', 'Ù' => 'U', 'Ú' => 'U', 'Û' => 'U', 'Ü' => 'U', 'Ű' => 'U', 'Ý' => 'Y', 'Þ' => 'TH', 290 | 'ß' => 'ss', 291 | 'à' => 'a', 'á' => 'a', 'â' => 'a', 'ã' => 'a', 'ä' => 'a', 'å' => 'a', 'æ' => 'ae', 'ç' => 'c', 292 | 'è' => 'e', 'é' => 'e', 'ê' => 'e', 'ë' => 'e', 'ì' => 'i', 'í' => 'i', 'î' => 'i', 'ï' => 'i', 293 | 'ð' => 'd', 'ñ' => 'n', 'ò' => 'o', 'ó' => 'o', 'ô' => 'o', 'õ' => 'o', 'ö' => 'o', 'ő' => 'o', 294 | 'ø' => 'o', 'ù' => 'u', 'ú' => 'u', 'û' => 'u', 'ü' => 'u', 'ű' => 'u', 'ý' => 'y', 'þ' => 'th', 295 | 'ÿ' => 'y', 296 | ]; 297 | 298 | /** 299 | * @var string|Transliterator Either a {@see Transliterator}, or a string from which a {@see Transliterator} 300 | * can be built for transliteration. Used by {@see toTransliterated()} when intl is available. 301 | * Defaults to {@see TRANSLITERATE_LOOSE}. 302 | * 303 | * @see https://secure.php.net/manual/en/transliterator.transliterate.php 304 | */ 305 | private string|Transliterator $transliterator = self::TRANSLITERATE_LOOSE; 306 | 307 | private bool $withoutIntl = false; 308 | 309 | /** 310 | * @param string[] $rules The rules for converting a word into its plural form. 311 | * 312 | * The keys are the regular expressions and the values are the corresponding replacements. 313 | * @psalm-param non-empty-array $rules 314 | * 315 | * @return self 316 | */ 317 | public function withPluralizeRules(array $rules): self 318 | { 319 | $new = clone $this; 320 | $new->pluralizeRules = $rules; 321 | return $new; 322 | } 323 | 324 | /** 325 | * @return string[] The rules for converting a word into its plural form. 326 | * The keys are the regular expressions and the values are the corresponding replacements. 327 | */ 328 | public function getPluralizeRules(): array 329 | { 330 | return $this->pluralizeRules; 331 | } 332 | 333 | /** 334 | * @param string[] $rules The rules for converting a word into its singular form. 335 | * The keys are the regular expressions and the values are the corresponding replacements. 336 | * 337 | * @psalm-param non-empty-array $rules 338 | * 339 | * @return self 340 | */ 341 | public function withSingularizeRules(array $rules): self 342 | { 343 | $new = clone $this; 344 | $new->singularizeRules = $rules; 345 | return $new; 346 | } 347 | 348 | /** 349 | * @return string[] The rules for converting a word into its singular form. 350 | * The keys are the regular expressions and the values are the corresponding replacements. 351 | */ 352 | public function getSingularizeRules(): array 353 | { 354 | return $this->singularizeRules; 355 | } 356 | 357 | /** 358 | * @param string[] $rules The special rules for converting a word between its plural form and singular form. 359 | * 360 | * @psalm-param array $rules 361 | * The keys are the special words in singular form, and the values are the corresponding plural form. 362 | * 363 | * @return self 364 | */ 365 | public function withSpecialRules(array $rules): self 366 | { 367 | $new = clone $this; 368 | $new->specialRules = $rules; 369 | return $new; 370 | } 371 | 372 | /** 373 | * @return string[] The special rules for converting a word between its plural form and singular form. 374 | * The keys are the special words in singular form, and the values are the corresponding plural form. 375 | */ 376 | public function getSpecialRules(): array 377 | { 378 | return $this->specialRules; 379 | } 380 | 381 | /** 382 | * @param string|Transliterator $transliterator Either a {@see \Transliterator}, or a string from which 383 | * a {@see \Transliterator} can be built for transliteration. Used by {@see toTransliterated()} when intl is available. 384 | * Defaults to {@see TRANSLITERATE_LOOSE}. 385 | * 386 | * @return self 387 | * 388 | * @see https://secure.php.net/manual/en/transliterator.transliterate.php 389 | */ 390 | public function withTransliterator($transliterator): self 391 | { 392 | $new = clone $this; 393 | $new->transliterator = $transliterator; 394 | return $new; 395 | } 396 | 397 | /** 398 | * @param string[] $transliterationMap Fallback map for transliteration used by {@see toTransliterated()} when intl 399 | * isn't available or turned off with {@see withoutIntl()}. 400 | * 401 | * @return $this 402 | */ 403 | public function withTransliterationMap(array $transliterationMap): self 404 | { 405 | $new = clone $this; 406 | $new->transliterationMap = $transliterationMap; 407 | return $new; 408 | } 409 | 410 | /** 411 | * Disables usage of intl for {@see toTransliterated()}. 412 | * 413 | * @return self 414 | */ 415 | public function withoutIntl(): self 416 | { 417 | $new = clone $this; 418 | $new->withoutIntl = true; 419 | return $new; 420 | } 421 | 422 | /** 423 | * Converts a word to its plural form. 424 | * Note that this is for English only! 425 | * For example, "apple" will become "apples", and "child" will become "children". 426 | * 427 | * @param string $input The word to be pluralized. 428 | * 429 | * @return string The pluralized word. 430 | */ 431 | public function toPlural(string $input): string 432 | { 433 | if (isset($this->specialRules[$input])) { 434 | return $this->specialRules[$input]; 435 | } 436 | foreach ($this->pluralizeRules as $rule => $replacement) { 437 | if (preg_match($rule, $input)) { 438 | /** @var string `$rule` and `$replacement` always correct, so `preg_replace` always returns string */ 439 | return preg_replace($rule, $replacement, $input); 440 | } 441 | } 442 | 443 | return $input; 444 | } 445 | 446 | /** 447 | * Returns the singular of the $word. 448 | * 449 | * @param string $input The english word to singularize. 450 | * 451 | * @return string Singular noun. 452 | */ 453 | public function toSingular(string $input): string 454 | { 455 | $result = array_search($input, $this->specialRules, true); 456 | 457 | if ($result !== false) { 458 | return $result; 459 | } 460 | 461 | foreach ($this->singularizeRules as $rule => $replacement) { 462 | if (preg_match($rule, $input)) { 463 | /** @var string `$rule` and `$replacement` always correct, so `preg_replace` always returns string */ 464 | return preg_replace($rule, $replacement, $input); 465 | } 466 | } 467 | 468 | return $input; 469 | } 470 | 471 | /** 472 | * Converts an underscored or PascalCase word into a English 473 | * sentence. 474 | * 475 | * @param string $input The string to titleize. 476 | * @param bool $uppercaseAll Whether to set all words to uppercase. 477 | * 478 | * @return string 479 | */ 480 | public function toSentence(string $input, bool $uppercaseAll = false): string 481 | { 482 | $input = $this->toHumanReadable($this->pascalCaseToId($input, '_'), $uppercaseAll); 483 | 484 | return $uppercaseAll 485 | ? StringHelper::uppercaseFirstCharacterInEachWord($input) 486 | : StringHelper::uppercaseFirstCharacter($input); 487 | } 488 | 489 | /** 490 | * Converts a string into space-separated words. 491 | * For example, 'PostTag' will be converted to 'Post Tag'. 492 | * 493 | * @param string $input The valid UTF-8 string to be converted. 494 | * 495 | * @return string The resulting words. 496 | */ 497 | public function toWords(string $input): string 498 | { 499 | /** 500 | * @var string $words We assume that `$input` is valid UTF-8 string, so `preg_replace()` never returns `false`. 501 | */ 502 | $words = preg_replace('/(?toPascalCase($input); 599 | 600 | return mb_strtolower(mb_substr($input, 0, 1)) . mb_substr($input, 1); 601 | } 602 | 603 | /** 604 | * Returns given word as "snake_cased". 605 | * 606 | * Converts a word like "userName" to "user_name". 607 | * It will remove non-alphanumeric character from the word, 608 | * so "who's online" will be converted to "who_s_online". 609 | * 610 | * @param string $input The word to convert. It must be valid UTF-8 string. 611 | * @param bool $strict Whether to insert a separator between two consecutive uppercase chars, defaults to true. 612 | * 613 | * @return string The "snake_cased" string. 614 | */ 615 | public function toSnakeCase(string $input, bool $strict = true): string 616 | { 617 | /** 618 | * @var string $input We assume that `$input` is valid UTF-8 string, so `preg_replace()` never returns `false`. 619 | */ 620 | $input = preg_replace('/[^\pL\pN]+/u', '_', $input); 621 | return $this->pascalCaseToId($input, '_', $strict); 622 | } 623 | 624 | /** 625 | * Converts a class name to its table name (pluralized) naming conventions. 626 | * 627 | * For example, converts "Car" to "cars", "Person" to "people", and "ActionLog" to "action_log". 628 | * 629 | * @param string $className the class name for getting related table_name. 630 | * 631 | * @return string 632 | */ 633 | public function classToTable(string $className): string 634 | { 635 | return $this->toPlural($this->pascalCaseToId($className, '_')); 636 | } 637 | 638 | /** 639 | * Converts a table name to its class name. 640 | * 641 | * For example, converts "cars" to "Car", "people" to "Person", and "action_log" to "ActionLog". 642 | * 643 | * @return string 644 | */ 645 | public function tableToClass(string $tableName): string 646 | { 647 | return $this->toPascalCase($this->toSingular($tableName)); 648 | } 649 | 650 | /** 651 | * Returns a string with all spaces converted to given replacement, 652 | * non word characters removed and the rest of characters transliterated. 653 | * 654 | * If intl extension isn't available uses fallback that converts latin characters only 655 | * and removes the rest. You may customize characters map via $transliteration property 656 | * of the helper. 657 | * 658 | * @param string $input An arbitrary valid UTF-8 string to convert. 659 | * @param string $replacement The replacement to use for spaces. It must be valid UTF-8 string. 660 | * @param bool $lowercase whether to return the string in lowercase or not. Defaults to `true`. 661 | * 662 | * @return string The converted string. 663 | */ 664 | public function toSlug(string $input, string $replacement = '-', bool $lowercase = true): string 665 | { 666 | $quotedReplacement = preg_quote($replacement, '/'); 667 | 668 | /** 669 | * Replace all non-words character 670 | * 671 | * @var string $input We assume that `$input` and `$replacement` are valid UTF-8 strings, so `preg_replace()` 672 | * never returns `false`. 673 | */ 674 | $input = preg_replace('/[^a-zA-Z0-9]+/u', $replacement, $this->toTransliterated($input)); 675 | 676 | /** 677 | * Remove first and last replacements 678 | * 679 | * @var string $input We assume that `$input` and `$quotedReplacement` are valid UTF-8 strings, so 680 | * `preg_replace()` never returns `false`. 681 | */ 682 | $input = preg_replace( 683 | "/^(?:$quotedReplacement)+|(?:$quotedReplacement)+$/u" . ($lowercase ? 'i' : ''), 684 | '', 685 | $input, 686 | ); 687 | 688 | return $lowercase ? strtolower($input) : $input; 689 | } 690 | 691 | /** 692 | * Returns transliterated version of a string. 693 | * 694 | * If intl extension isn't available uses fallback that converts latin characters only 695 | * and removes the rest. You may customize characters map via $transliteration property 696 | * of the helper. 697 | * 698 | * @noinspection PhpComposerExtensionStubsInspection 699 | * 700 | * @param string $input Input string. It must be valid UTF-8 string. 701 | * @param string|Transliterator|null $transliterator either a {@see \Transliterator} or a string 702 | * from which a {@see \Transliterator} can be built. If null, value set with {@see withTransliterator()} 703 | * or {@see TRANSLITERATE_LOOSE} is used. 704 | */ 705 | public function toTransliterated(string $input, $transliterator = null): string 706 | { 707 | if ($this->useIntl()) { 708 | if ($transliterator === null) { 709 | $transliterator = $this->transliterator; 710 | } 711 | 712 | /** 713 | * @noinspection PhpComposerExtensionStubsInspection 714 | * @var string We assume that `$input` are valid UTF-8 strings and `$transliterator` is valid, so 715 | * `preg_replace()` never returns `false`. 716 | */ 717 | return transliterator_transliterate($transliterator, $input); 718 | } 719 | 720 | return strtr($input, $this->transliterationMap); 721 | } 722 | 723 | /** 724 | * @return bool If intl extension should be used. 725 | */ 726 | private function useIntl(): bool 727 | { 728 | return $this->withoutIntl === false && extension_loaded('intl'); 729 | } 730 | } 731 | -------------------------------------------------------------------------------- /src/MemoizedCombinedRegexp.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | private array $results = []; 19 | 20 | public function __construct( 21 | private readonly AbstractCombinedRegexp $decorated, 22 | ) { 23 | } 24 | 25 | public function getCompiledPattern(): string 26 | { 27 | return $this->decorated->getCompiledPattern(); 28 | } 29 | 30 | public function matches(string $string): bool 31 | { 32 | $this->evaluate($string); 33 | 34 | return $this->results[$string]['matches']; 35 | } 36 | 37 | public function getMatchingPattern(string $string): string 38 | { 39 | $this->evaluate($string); 40 | 41 | return $this->getPatterns()[$this->getMatchingPatternPosition($string)]; 42 | } 43 | 44 | public function getMatchingPatternPosition(string $string): int 45 | { 46 | $this->evaluate($string); 47 | 48 | return $this->results[$string]['position'] ?? $this->throwFailedMatchException($string); 49 | } 50 | 51 | private function evaluate(string $string): void 52 | { 53 | if (isset($this->results[$string])) { 54 | return; 55 | } 56 | try { 57 | $position = $this->decorated->getMatchingPatternPosition($string); 58 | 59 | $this->results[$string]['matches'] = true; 60 | $this->results[$string]['position'] = $position; 61 | } catch (\Exception) { 62 | $this->results[$string]['matches'] = false; 63 | } 64 | } 65 | 66 | public function getPatterns(): array 67 | { 68 | return $this->decorated->getPatterns(); 69 | } 70 | 71 | public function getFlags(): string 72 | { 73 | return $this->decorated->getFlags(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/NumericHelper.php: -------------------------------------------------------------------------------- 1 | $value . 'st', 47 | 2 => $value . 'nd', 48 | 3 => $value . 'rd', 49 | default => $value . 'th', 50 | }; 51 | } 52 | 53 | /** 54 | * Returns string representation of a number value without thousands separators and with dot as decimal separator. 55 | * 56 | * @param bool|float|int|string|Stringable $value String in `string` or `Stringable` must be valid UTF-8 string. 57 | * 58 | * @throws InvalidArgumentException if value is not scalar. 59 | */ 60 | public static function normalize(mixed $value): string 61 | { 62 | /** @psalm-suppress DocblockTypeContradiction */ 63 | if (!is_scalar($value) && !$value instanceof Stringable) { 64 | $type = gettype($value); 65 | throw new InvalidArgumentException("Value must be scalar. $type given."); 66 | } 67 | 68 | if (is_bool($value)) { 69 | return $value ? '1' : '0'; 70 | } 71 | 72 | $value = str_replace([' ', ','], ['', '.'], (string) $value); 73 | 74 | /** 75 | * @var string We assume that `$value` is valid UTF-8 string, so `preg_replace()` never returns `false`. 76 | */ 77 | return preg_replace('/\.(?=.*\.)/', '', $value); 78 | } 79 | 80 | /** 81 | * Checks whether the given string is an integer number. 82 | * 83 | * Require Filter PHP extension ({@see https://www.php.net/manual/intro.filter.php}). 84 | */ 85 | public static function isInteger(mixed $value): bool 86 | { 87 | return filter_var($value, FILTER_VALIDATE_INT) !== false; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/StringHelper.php: -------------------------------------------------------------------------------- 1 | 0 && mb_substr($path, -$length) === $suffix) { 94 | $path = mb_substr($path, 0, -$length); 95 | } 96 | $path = rtrim(str_replace('\\', '/', $path), '/\\'); 97 | $position = mb_strrpos($path, '/'); 98 | if ($position !== false) { 99 | return mb_substr($path, $position + 1); 100 | } 101 | 102 | return $path; 103 | } 104 | 105 | /** 106 | * Returns parent directory's path. 107 | * This method is similar to `dirname()` except that it will treat 108 | * both \ and / as directory separators, independent of the operating system. 109 | * 110 | * @param string $path A path string. 111 | * 112 | * @return string The parent directory's path. 113 | * 114 | * @see https://www.php.net/manual/en/function.basename.php 115 | */ 116 | public static function directoryName(string $path): string 117 | { 118 | $position = mb_strrpos(str_replace('\\', '/', $path), '/'); 119 | if ($position !== false) { 120 | return mb_substr($path, 0, $position); 121 | } 122 | 123 | return ''; 124 | } 125 | 126 | /** 127 | * Get part of string. 128 | * 129 | * @param string $string To get substring from. 130 | * @param int $start Character to start at. 131 | * @param int|null $length Number of characters to get. 132 | * @param string $encoding The encoding to use, defaults to "UTF-8". 133 | * 134 | * @see https://php.net/manual/en/function.mb-substr.php 135 | */ 136 | public static function substring(string $string, int $start, ?int $length = null, string $encoding = 'UTF-8'): string 137 | { 138 | return mb_substr($string, $start, $length, $encoding); 139 | } 140 | 141 | /** 142 | * Replace text within a portion of a string. 143 | * 144 | * @param string $string The input string. 145 | * @param string $replacement The replacement string. 146 | * @param int $start Position to begin replacing substring at. 147 | * If start is non-negative, the replacing will begin at the start'th offset into string. 148 | * If start is negative, the replacing will begin at the start'th character from the end of string. 149 | * @param int|null $length Length of the substring to be replaced. 150 | * If given and is positive, it represents the length of the portion of string which is to be replaced. 151 | * If it is negative, it represents the number of characters from the end of string at which to stop replacing. 152 | * If it is not given, then it will default to the length of the string; i.e. end the replacing at the end of string. 153 | * If length is zero then this function will have the effect of inserting replacement into string at the given start offset. 154 | * @param string $encoding The encoding to use, defaults to "UTF-8". 155 | */ 156 | public static function replaceSubstring( 157 | string $string, 158 | string $replacement, 159 | int $start, 160 | int|null $length = null, 161 | string $encoding = 'UTF-8', 162 | ): string { 163 | $stringLength = mb_strlen($string, $encoding); 164 | 165 | if ($start < 0) { 166 | $start = max(0, $stringLength + $start); 167 | } elseif ($start > $stringLength) { 168 | $start = $stringLength; 169 | } 170 | 171 | if ($length !== null && $length < 0) { 172 | $length = max(0, $stringLength - $start + $length); 173 | } elseif ($length === null || $length > $stringLength) { 174 | $length = $stringLength; 175 | } 176 | 177 | if (($start + $length) > $stringLength) { 178 | $length = $stringLength - $start; 179 | } 180 | 181 | return mb_substr($string, 0, $start, $encoding) 182 | . $replacement 183 | . mb_substr($string, $start + $length, $stringLength - $start - $length, $encoding); 184 | } 185 | 186 | /** 187 | * Check if given string starts with specified substring. 188 | * Binary and multibyte safe. 189 | * 190 | * @param string $input Input string. 191 | * @param string|null $with Part to search inside the $string. 192 | * 193 | * @return bool Returns true if first input starts with second input, false otherwise. 194 | */ 195 | public static function startsWith(string $input, string|null $with): bool 196 | { 197 | return $with === null || str_starts_with($input, $with); 198 | } 199 | 200 | /** 201 | * Check if given string starts with specified substring ignoring case. 202 | * Binary and multibyte safe. 203 | * 204 | * @param string $input Input string. 205 | * @param string|null $with Part to search inside the $string. 206 | * 207 | * @return bool Returns true if first input starts with second input, false otherwise. 208 | */ 209 | public static function startsWithIgnoringCase(string $input, string|null $with): bool 210 | { 211 | $bytes = self::byteLength($with); 212 | 213 | if ($bytes === 0) { 214 | return true; 215 | } 216 | 217 | /** @psalm-suppress PossiblyNullArgument */ 218 | return self::lowercase(self::substring($input, 0, $bytes, '8bit')) === self::lowercase($with); 219 | } 220 | 221 | /** 222 | * Check if given string ends with specified substring. 223 | * Binary and multibyte safe. 224 | * 225 | * @param string $input Input string to check. 226 | * @param string|null $with Part to search inside of the $string. 227 | * 228 | * @return bool Returns true if first input ends with second input, false otherwise. 229 | */ 230 | public static function endsWith(string $input, string|null $with): bool 231 | { 232 | return $with === null || str_ends_with($input, $with); 233 | } 234 | 235 | /** 236 | * Check if given string ends with specified substring. 237 | * Binary and multibyte safe. 238 | * 239 | * @param string $input Input string to check. 240 | * @param string|null $with Part to search inside of the $string. 241 | * 242 | * @return bool Returns true if first input ends with second input, false otherwise. 243 | */ 244 | public static function endsWithIgnoringCase(string $input, string|null $with): bool 245 | { 246 | $bytes = self::byteLength($with); 247 | 248 | if ($bytes === 0) { 249 | return true; 250 | } 251 | 252 | /** @psalm-suppress PossiblyNullArgument */ 253 | return self::lowercase(mb_substr($input, -$bytes, mb_strlen($input, '8bit'), '8bit')) === self::lowercase($with); 254 | } 255 | 256 | /** 257 | * Truncates a string from the beginning to the number of characters specified. 258 | * 259 | * @param string $input String to process. 260 | * @param int $length Maximum length of the truncated string including trim marker. 261 | * @param string $trimMarker String to append to the beginning. 262 | * @param string $encoding The encoding to use, defaults to "UTF-8". 263 | */ 264 | public static function truncateBegin(string $input, int $length, string $trimMarker = '…', string $encoding = 'UTF-8'): string 265 | { 266 | $inputLength = mb_strlen($input, $encoding); 267 | 268 | if ($inputLength <= $length) { 269 | return $input; 270 | } 271 | 272 | $trimMarkerLength = mb_strlen($trimMarker, $encoding); 273 | return self::replaceSubstring($input, $trimMarker, 0, -$length + $trimMarkerLength, $encoding); 274 | } 275 | 276 | /** 277 | * Truncates a string in the middle. Keeping start and end. 278 | * `StringHelper::truncateMiddle('Hello world number 2', 8)` produces "Hell…r 2". 279 | * 280 | * @param string $input The string to truncate. 281 | * @param int $length Maximum length of the truncated string including trim marker. 282 | * @param string $trimMarker String to append in the middle of truncated string. 283 | * @param string $encoding The encoding to use, defaults to "UTF-8". 284 | * 285 | * @return string The truncated string. 286 | */ 287 | public static function truncateMiddle(string $input, int $length, string $trimMarker = '…', string $encoding = 'UTF-8'): string 288 | { 289 | $inputLength = mb_strlen($input, $encoding); 290 | 291 | if ($inputLength <= $length) { 292 | return $input; 293 | } 294 | 295 | $trimMarkerLength = mb_strlen($trimMarker, $encoding); 296 | $start = (int)ceil(($length - $trimMarkerLength) / 2); 297 | $end = $length - $start - $trimMarkerLength; 298 | 299 | return self::replaceSubstring($input, $trimMarker, $start, -$end, $encoding); 300 | } 301 | 302 | /** 303 | * Truncates a string from the end to the number of characters specified. 304 | * 305 | * @param string $input The string to truncate. 306 | * @param int $length Maximum length of the truncated string including trim marker. 307 | * @param string $trimMarker String to append to the end of truncated string. 308 | * @param string $encoding The encoding to use, defaults to "UTF-8". 309 | * 310 | * @return string The truncated string. 311 | */ 312 | public static function truncateEnd(string $input, int $length, string $trimMarker = '…', string $encoding = 'UTF-8'): string 313 | { 314 | $inputLength = mb_strlen($input, $encoding); 315 | 316 | if ($inputLength <= $length) { 317 | return $input; 318 | } 319 | 320 | $trimMarkerLength = mb_strlen($trimMarker, $encoding); 321 | return rtrim(mb_substr($input, 0, $length - $trimMarkerLength, $encoding)) . $trimMarker; 322 | } 323 | 324 | /** 325 | * Truncates a string to the number of words specified. 326 | * 327 | * @param string $input The string to truncate. 328 | * @param int $count How many words from original string to include into truncated string. 329 | * @param string $trimMarker String to append to the end of truncated string. 330 | * 331 | * @return string The truncated string. 332 | */ 333 | public static function truncateWords(string $input, int $count, string $trimMarker = '…'): string 334 | { 335 | /** @psalm-var list $words */ 336 | $words = preg_split('/(\s+)/u', trim($input), -1, PREG_SPLIT_DELIM_CAPTURE); 337 | if (count($words) / 2 > $count) { 338 | $words = array_slice($words, 0, ($count * 2) - 1); 339 | return implode('', $words) . $trimMarker; 340 | } 341 | 342 | return $input; 343 | } 344 | 345 | /** 346 | * Get string length. 347 | * 348 | * @param string $string String to calculate length for. 349 | * @param string $encoding The encoding to use, defaults to "UTF-8". 350 | * 351 | * @see https://php.net/manual/en/function.mb-strlen.php 352 | */ 353 | public static function length(string $string, string $encoding = 'UTF-8'): int 354 | { 355 | return mb_strlen($string, $encoding); 356 | } 357 | 358 | /** 359 | * Counts words in a string. 360 | */ 361 | public static function countWords(string $input): int 362 | { 363 | /** @var array $words */ 364 | $words = preg_split('/\s+/u', $input, -1, PREG_SPLIT_NO_EMPTY); 365 | return count($words); 366 | } 367 | 368 | /** 369 | * Make a string lowercase. 370 | * 371 | * @param string $string String to process. 372 | * @param string $encoding The encoding to use, defaults to "UTF-8". 373 | * 374 | * @see https://php.net/manual/en/function.mb-strtolower.php 375 | */ 376 | public static function lowercase(string $string, string $encoding = 'UTF-8'): string 377 | { 378 | return mb_strtolower($string, $encoding); 379 | } 380 | 381 | /** 382 | * Make a string uppercase. 383 | * 384 | * @param string $string String to process. 385 | * @param string $encoding The encoding to use, defaults to "UTF-8". 386 | * 387 | * @see https://php.net/manual/en/function.mb-strtoupper.php 388 | */ 389 | public static function uppercase(string $string, string $encoding = 'UTF-8'): string 390 | { 391 | return mb_strtoupper($string, $encoding); 392 | } 393 | 394 | /** 395 | * Make a string's first character uppercase. 396 | * 397 | * @param string $string The string to be processed. 398 | * @param string $encoding The encoding to use, defaults to "UTF-8". 399 | * 400 | * @see https://php.net/manual/en/function.ucfirst.php 401 | */ 402 | public static function uppercaseFirstCharacter(string $string, string $encoding = 'UTF-8'): string 403 | { 404 | $firstCharacter = self::substring($string, 0, 1, $encoding); 405 | $rest = self::substring($string, 1, null, $encoding); 406 | 407 | return self::uppercase($firstCharacter, $encoding) . $rest; 408 | } 409 | 410 | /** 411 | * Uppercase the first character of each word in a string. 412 | * 413 | * @param string $string The valid UTF-8 string to be processed. 414 | * @param string $encoding The encoding to use, defaults to "UTF-8". 415 | * 416 | * @see https://php.net/manual/en/function.ucwords.php 417 | */ 418 | public static function uppercaseFirstCharacterInEachWord(string $string, string $encoding = 'UTF-8'): string 419 | { 420 | /** 421 | * @var array $words We assume that `$string` is valid UTF-8 string, so `preg_split()` never returns `false`. 422 | */ 423 | $words = preg_split('/\s/u', $string, -1, PREG_SPLIT_NO_EMPTY); 424 | 425 | $wordsWithUppercaseFirstCharacter = array_map( 426 | static fn (string $word) => self::uppercaseFirstCharacter($word, $encoding), 427 | $words 428 | ); 429 | 430 | return implode(' ', $wordsWithUppercaseFirstCharacter); 431 | } 432 | 433 | /** 434 | * Encodes string into "Base 64 Encoding with URL and Filename Safe Alphabet" (RFC 4648). 435 | * 436 | * > Note: Base 64 padding `=` may be at the end of the returned string. 437 | * > `=` is not transparent to URL encoding. 438 | * 439 | * @see https://tools.ietf.org/html/rfc4648#page-7 440 | * 441 | * @param string $input The string to encode. 442 | * 443 | * @return string Encoded string. 444 | * 445 | * @psalm-template T as string 446 | * @psalm-param T $input 447 | * @psalm-return (T is non-empty-string ? non-empty-string : "") 448 | */ 449 | public static function base64UrlEncode(string $input): string 450 | { 451 | return strtr(base64_encode($input), '+/', '-_'); 452 | } 453 | 454 | /** 455 | * Decodes "Base 64 Encoding with URL and Filename Safe Alphabet" (RFC 4648). 456 | * 457 | * @see https://tools.ietf.org/html/rfc4648#page-7 458 | * 459 | * @param string $input Encoded string. 460 | * 461 | * @return string Decoded string. 462 | */ 463 | public static function base64UrlDecode(string $input): string 464 | { 465 | return base64_decode(strtr($input, '-_', '+/')); 466 | } 467 | 468 | /** 469 | * Split a string to array with non-empty lines. 470 | * Whitespace from the beginning and end of a each line will be stripped. 471 | * 472 | * @param string $string The input string. It must be valid UTF-8 string. 473 | * @param string $separator The boundary string. It is a part of regular expression 474 | * so should be taken into account or properly escaped with {@see preg_quote()}. It must be valid UTF-8 string. 475 | */ 476 | public static function split(string $string, string $separator = '\R'): array 477 | { 478 | /** 479 | * @var string $string We assume that `$string` is valid UTF-8 string, so `preg_replace()` never returns 480 | * `false`. 481 | */ 482 | $string = preg_replace('(^\s*|\s*$)', '', $string); 483 | 484 | /** 485 | * @var array We assume that $separator is prepared by `preg_quote()` and $string is valid UTF-8 string, 486 | * so `preg_split()` never returns `false`. 487 | */ 488 | return preg_split('~\s*' . $separator . '\s*~u', $string, -1, PREG_SPLIT_NO_EMPTY); 489 | } 490 | 491 | /** 492 | * @param string $path The path of where do you want to write a value to `$array`. The path can be described by 493 | * a string when each key should be separated by delimiter. If a path item contains delimiter, it can be escaped 494 | * with "\" (backslash) or a custom delimiter can be used. 495 | * @param string $delimiter A separator, used to parse string key for embedded object property retrieving. Defaults 496 | * to "." (dot). 497 | * @param string $escapeCharacter An escape character, used to escape delimiter. Defaults to "\" (backslash). 498 | * @param bool $preserveDelimiterEscaping Whether to preserve delimiter escaping in the items of final array (in 499 | * case of using string as an input). When `false`, "\" (backslashes) are removed. For a "." as delimiter, "." 500 | * becomes "\.". Defaults to `false`. 501 | * 502 | * @return string[] 503 | * 504 | * @psalm-return non-empty-list 505 | */ 506 | public static function parsePath( 507 | string $path, 508 | string $delimiter = '.', 509 | string $escapeCharacter = '\\', 510 | bool $preserveDelimiterEscaping = false 511 | ): array { 512 | if (strlen($delimiter) !== 1) { 513 | throw new InvalidArgumentException('Only 1 character is allowed for delimiter.'); 514 | } 515 | 516 | if (strlen($escapeCharacter) !== 1) { 517 | throw new InvalidArgumentException('Only 1 escape character is allowed.'); 518 | } 519 | 520 | if ($delimiter === $escapeCharacter) { 521 | throw new InvalidArgumentException('Delimiter and escape character must be different.'); 522 | } 523 | 524 | if ($path === '') { 525 | return ['']; 526 | } 527 | 528 | if (!str_contains($path, $delimiter)) { 529 | if ($preserveDelimiterEscaping) { 530 | return [$path]; 531 | } 532 | 533 | return [str_replace($escapeCharacter . $escapeCharacter, $escapeCharacter, $path)]; 534 | } 535 | 536 | /** @psalm-var non-empty-list $matches */ 537 | $matches = preg_split( 538 | sprintf( 539 | '/(?%1$s%1$s)*)%2$s/', 540 | preg_quote($escapeCharacter, '/'), 541 | preg_quote($delimiter, '/') 542 | ), 543 | $path, 544 | -1, 545 | PREG_SPLIT_OFFSET_CAPTURE 546 | ); 547 | $result = []; 548 | $countResults = count($matches); 549 | for ($i = 1; $i < $countResults; $i++) { 550 | $l = $matches[$i][1] - $matches[$i - 1][1] - strlen($matches[$i - 1][0]) - 1; 551 | $result[] = $matches[$i - 1][0] . ($l > 0 ? str_repeat($escapeCharacter, $l) : ''); 552 | } 553 | $result[] = $matches[$countResults - 1][0]; 554 | 555 | if ($preserveDelimiterEscaping === true) { 556 | return $result; 557 | } 558 | 559 | return array_map( 560 | static fn (string $key): string => str_replace( 561 | [ 562 | $escapeCharacter . $escapeCharacter, 563 | $escapeCharacter . $delimiter, 564 | ], 565 | [ 566 | $escapeCharacter, 567 | $delimiter, 568 | ], 569 | $key 570 | ), 571 | $result 572 | ); 573 | } 574 | 575 | /** 576 | * Strip Unicode whitespace (with Unicode symbol property White_Space=yes) or other characters from the beginning and end of a string. 577 | * Input string and pattern are treated as UTF-8. 578 | * 579 | * @see https://en.wikipedia.org/wiki/Whitespace_character#Unicode 580 | * @see https://www.php.net/manual/function.preg-replace 581 | * 582 | * @param string|string[] $string The string or an array with strings. 583 | * @param string $pattern PCRE regex pattern to search for, as UTF-8 string. Use {@see preg_quote()} to quote `$pattern` if it contains 584 | * special regular expression characters. 585 | * 586 | * @psalm-template TKey of array-key 587 | * @psalm-param string|array $string 588 | * @psalm-param non-empty-string $pattern 589 | * @psalm-return ($string is array ? array : string) 590 | * 591 | * @return string|string[] 592 | */ 593 | public static function trim(string|array $string, string $pattern = self::DEFAULT_WHITESPACE_PATTERN): string|array 594 | { 595 | self::ensureUtf8String($string); 596 | self::ensureUtf8Pattern($pattern); 597 | 598 | /** 599 | * @var string|string[] `$string` is correct UTF-8 string and `$pattern` is correct (it should be passed 600 | * already prepared), so `preg_replace` never returns `null`. 601 | */ 602 | return preg_replace("#^[$pattern]+|[$pattern]+$#uD", '', $string); 603 | } 604 | 605 | /** 606 | * Strip Unicode whitespace (with Unicode symbol property White_Space=yes) or other characters from the beginning of a string. 607 | * 608 | * @see self::trim() 609 | * 610 | * @param string|string[] $string The string or an array with strings. 611 | * @param string $pattern PCRE regex pattern to search for, as UTF-8 string. Use {@see preg_quote()} to quote `$pattern` if it contains 612 | * special regular expression characters. 613 | * 614 | * @psalm-template TKey of array-key 615 | * @psalm-param string|array $string 616 | * @psalm-param non-empty-string $pattern 617 | * @psalm-return ($string is array ? array : string) 618 | * 619 | * @return string|string[] 620 | */ 621 | public static function ltrim(string|array $string, string $pattern = self::DEFAULT_WHITESPACE_PATTERN): string|array 622 | { 623 | self::ensureUtf8String($string); 624 | self::ensureUtf8Pattern($pattern); 625 | 626 | /** 627 | * @var string|string[] `$string` is correct UTF-8 string and `$pattern` is correct (it should be passed 628 | * already prepared), so `preg_replace` never returns `null`. 629 | */ 630 | return preg_replace("#^[$pattern]+#u", '', $string); 631 | } 632 | 633 | /** 634 | * Strip Unicode whitespace (with Unicode symbol property White_Space=yes) or other characters from the end of a string. 635 | * 636 | * @see self::trim() 637 | * 638 | * @param string|string[] $string The string or an array with strings. 639 | * @param string $pattern PCRE regex pattern to search for, as UTF-8 string. Use {@see preg_quote()} to quote `$pattern` if it contains 640 | * special regular expression characters. 641 | * 642 | * @psalm-template TKey of array-key 643 | * @psalm-param string|array $string 644 | * @psalm-param non-empty-string $pattern 645 | * @psalm-return ($string is array ? array : string) 646 | * 647 | * @return string|string[] 648 | */ 649 | public static function rtrim(string|array $string, string $pattern = self::DEFAULT_WHITESPACE_PATTERN): string|array 650 | { 651 | self::ensureUtf8String($string); 652 | self::ensureUtf8Pattern($pattern); 653 | 654 | /** 655 | * @var string|string[] `$string` is correct UTF-8 string and `$pattern` is correct (it should be passed 656 | * already prepared), so `preg_replace` never returns `null`. 657 | */ 658 | return preg_replace("#[$pattern]+$#uD", '', $string); 659 | } 660 | 661 | /** 662 | * Returns the portion of the string that lies between the first occurrence of the `$start` string 663 | * and the last occurrence of the `$end` string after that. 664 | * 665 | * @param string $string The input string. 666 | * @param string $start The string marking the start of the portion to extract. 667 | * @param string|null $end The string marking the end of the portion to extract. 668 | * If the `$end` string is not provided, it defaults to the value of the `$start` string. 669 | * @return string|null The portion of the string between the first occurrence of 670 | * `$start` and the last occurrence of `$end`, or null if either `$start` or `$end` cannot be found. 671 | */ 672 | public static function findBetween(string $string, string $start, ?string $end = null): ?string 673 | { 674 | if ($end === null) { 675 | $end = $start; 676 | } 677 | 678 | $startPos = mb_strpos($string, $start); 679 | 680 | if ($startPos === false) { 681 | return null; 682 | } 683 | 684 | $startPos += mb_strlen($start); 685 | $endPos = mb_strrpos($string, $end, $startPos); 686 | 687 | if ($endPos === false) { 688 | return null; 689 | } 690 | 691 | return mb_substr($string, $startPos, $endPos - $startPos); 692 | } 693 | 694 | /** 695 | * Returns the portion of the string between the initial occurrence of the '$start' string 696 | * and the next occurrence of the '$end' string. 697 | * 698 | * @param string $string The input string. 699 | * @param string $start The string marking the beginning of the segment to extract. 700 | * @param string|null $end The string marking the termination of the segment. 701 | * If the '$end' string is not provided, it defaults to the value of the '$start' string. 702 | * @return string|null Extracted segment, or null if '$start' or '$end' is not present. 703 | */ 704 | public static function findBetweenFirst(string $string, string $start, ?string $end = null): ?string 705 | { 706 | if ($end === null) { 707 | $end = $start; 708 | } 709 | 710 | $startPos = mb_strpos($string, $start); 711 | 712 | if ($startPos === false) { 713 | return null; 714 | } 715 | 716 | $startPos += mb_strlen($start); 717 | $endPos = mb_strpos($string, $end, $startPos); 718 | 719 | if ($endPos === false) { 720 | return null; 721 | } 722 | 723 | return mb_substr($string, $startPos, $endPos - $startPos); 724 | } 725 | 726 | /** 727 | * Returns the portion of the string between the latest '$start' string 728 | * and the subsequent '$end' string. 729 | * 730 | * @param string $string The input string. 731 | * @param string $start The string marking the beginning of the segment to extract. 732 | * @param string|null $end The string marking the termination of the segment. 733 | * If the '$end' string is not provided, it defaults to the value of the '$start' string. 734 | * @return string|null Extracted segment, or null if '$start' or '$end' is not present. 735 | */ 736 | public static function findBetweenLast(string $string, string $start, ?string $end = null): ?string 737 | { 738 | if ($end === null) { 739 | $end = $start; 740 | } 741 | 742 | $endPos = mb_strrpos($string, $end); 743 | 744 | if ($endPos === false) { 745 | return null; 746 | } 747 | 748 | $startPos = mb_strrpos(mb_substr($string, 0, $endPos), $start); 749 | 750 | if ($startPos === false) { 751 | return null; 752 | } 753 | 754 | $startPos += mb_strlen($start); 755 | 756 | return mb_substr($string, $startPos, $endPos - $startPos); 757 | } 758 | 759 | /** 760 | * Checks if a given string matches any of the provided patterns. 761 | * 762 | * Note that patterns should be provided without delimiters on both sides. For example, `te(s|x)t`. 763 | * 764 | * @see https://www.php.net/manual/reference.pcre.pattern.syntax.php 765 | * @see https://www.php.net/manual/reference.pcre.pattern.modifiers.php 766 | * 767 | * @param string $string The string to match against the patterns. 768 | * @param string[] $patterns Regular expressions without delimiters on both sides. 769 | * @param string $flags Flags to apply to all regular expressions. 770 | */ 771 | public static function matchAnyRegex(string $string, array $patterns, string $flags = ''): bool 772 | { 773 | if (empty($patterns)) { 774 | return false; 775 | } 776 | 777 | return (new CombinedRegexp($patterns, $flags))->matches($string); 778 | } 779 | 780 | /** 781 | * Ensure the pattern is a valid UTF-8 string. 782 | * 783 | * @param string $pattern The pattern. 784 | * 785 | * @throws InvalidArgumentException 786 | */ 787 | private static function ensureUtf8Pattern(string $pattern): void 788 | { 789 | if (!preg_match('##u', $pattern)) { 790 | throw new InvalidArgumentException('Pattern is not a valid UTF-8 string.'); 791 | } 792 | } 793 | 794 | /** 795 | * Ensure the string is a valid UTF-8 string. 796 | * 797 | * @param array|string $string The string. 798 | * 799 | * @throws InvalidArgumentException 800 | * 801 | * @psalm-param string|string[] $string 802 | */ 803 | private static function ensureUtf8String(string|array $string): void 804 | { 805 | foreach ((array) $string as $s) { 806 | if (!preg_match('##u', $s)) { 807 | throw new InvalidArgumentException('String is not a valid UTF-8 string.'); 808 | } 809 | } 810 | } 811 | } 812 | -------------------------------------------------------------------------------- /src/WildcardPattern.php: -------------------------------------------------------------------------------- 1 | pattern === '**') { 55 | return true; 56 | } 57 | 58 | return preg_match($this->getPatternPrepared(), $string) === 1; 59 | } 60 | 61 | /** 62 | * Make pattern case insensitive. 63 | */ 64 | public function ignoreCase(bool $flag = true): self 65 | { 66 | $new = clone $this; 67 | $new->patternPrepared = null; 68 | $new->ignoreCase = $flag; 69 | 70 | return $new; 71 | } 72 | 73 | /** 74 | * Returns whether the pattern contains a dynamic part i.e. 75 | * has unescaped "*", "{", "?", or "[" character. 76 | * 77 | * @param string $pattern The pattern to check. 78 | * 79 | * @return bool Whether the pattern contains a dynamic part. 80 | */ 81 | public static function isDynamic(string $pattern): bool 82 | { 83 | /** @var string $pattern `$rule` and `$replacement` always correct, so `preg_replace` always returns string */ 84 | $pattern = preg_replace('/\\\\./', '', $pattern); 85 | return preg_match('/[*{?\[]/', $pattern) === 1; 86 | } 87 | 88 | /** 89 | * Escapes pattern characters in a string. 90 | * 91 | * @param string $string Source string. 92 | * 93 | * @return string String with pattern characters escaped. 94 | */ 95 | public static function quote(string $string): string 96 | { 97 | /** @var string `$rule` and `$replacement` always correct, so `preg_replace` always returns string */ 98 | return preg_replace('#([\\\\?*\\[\\]])#', '\\\\$1', $string); 99 | } 100 | 101 | /** 102 | * @return non-empty-string 103 | */ 104 | private function getPatternPrepared(): string 105 | { 106 | if ($this->patternPrepared !== null) { 107 | return $this->patternPrepared; 108 | } 109 | 110 | $replacements = [ 111 | '\*\*' => '.*', 112 | '\\\\\\\\' => '\\\\', 113 | '\\\\\\*' => '[*]', 114 | '\\\\\\?' => '[?]', 115 | '\\\\\\[' => '[\[]', 116 | '\\\\\\]' => '[\]]', 117 | ]; 118 | 119 | if ($this->delimiters === []) { 120 | $replacements += [ 121 | '\*' => '.*', 122 | '\?' => '?', 123 | ]; 124 | } else { 125 | $notDelimiters = '[^' . preg_quote(implode('', $this->delimiters), '#') . ']'; 126 | $replacements += [ 127 | '\*' => "$notDelimiters*", 128 | '\?' => $notDelimiters, 129 | ]; 130 | } 131 | 132 | $replacements += [ 133 | '\[\!' => '[^', 134 | '\[' => '[', 135 | '\]' => ']', 136 | '\-' => '-', 137 | ]; 138 | 139 | $pattern = strtr(preg_quote($this->pattern, '#'), $replacements); 140 | $pattern = '#^' . $pattern . '$#us'; 141 | 142 | if ($this->ignoreCase) { 143 | $pattern .= 'i'; 144 | } 145 | 146 | $this->patternPrepared = $pattern; 147 | 148 | return $this->patternPrepared; 149 | } 150 | } 151 | --------------------------------------------------------------------------------