Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
98.98% covered (success)
98.98%
194 / 196
93.75% covered (success)
93.75%
15 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
Str
98.98% covered (success)
98.98%
194 / 196
93.75% covered (success)
93.75%
15 / 16
89
0.00% covered (danger)
0.00%
0 / 1
 createSlug
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 createLinks
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 createRandom
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 createRandomAlphaNum
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 createRandomAlpha
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 createRandomNumeric
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateRandomString
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 stripSpecialCharacters
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 matches
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 matchesWords
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 matchesWordsInSource
88.24% covered (success)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
7.08
 extractWords
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 __callStatic
100.00% covered (success)
100.00%
77 / 77
100.00% covered (success)
100.00%
1 / 1
36
 convertFromCamelCase
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 convertToCamelCase
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 detectSeparator
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Utils;
16
17/**
18 * Pop utils string helper class
19 *
20 * @category   Pop
21 * @package    Pop\Utils
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    3.0.0
26 */
27class Str
28{
29
30    /**
31     * Constants case type for random string generation
32     */
33    const MIXEDCASE = 0;
34    const LOWERCASE = 1;
35    const UPPERCASE = 2;
36
37    /**
38     * Characters for random string generation (certain characters omitted to eliminate confusion)
39     * @var array
40     */
41    protected static array $randomChars = [
42        'abcdefghjkmnpqrstuvwxyz',
43        'ABCDEFGHJKLMNPQRSTUVWXYZ',
44        '0123456789',
45        '!?#$%&@-_+*=,.:;()[]{}',
46    ];
47
48    /**
49     * Regex patterns & replacements for links
50     * @var array
51     */
52    protected static array $linksRegex = [
53        [
54            'pattern'     => '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,3}(\/\S*)?/m',
55            'replacement' => '<a href="$0">$0</a>'
56        ],
57        [
58            'pattern'     => '/[a-zA-Z0-9\.\-\_+%]+@[a-zA-Z0-9\-\_\.]+\.[a-zA-Z]{2,4}/m',
59            'replacement' => '<a href="mailto:$0">$0</a>'
60        ]
61    ];
62
63    /**
64     * Allowed keywords for converting cases
65     * @var array
66     */
67    protected static array $allowedCases = [
68        'titlecase', 'camelcase', 'kebabcase', 'dash', 'snakecase', 'underscore', 'namespace', 'path', 'url', 'uri'
69    ];
70
71    /**
72     * Convert the string into an SEO-friendly slug.
73     *
74     * @param  string $string
75     * @param  string $separator
76     * @return string
77     */
78    public static function createSlug(string $string, string $separator = '-'): string
79    {
80        $string = str_replace(' ', $separator, preg_replace('/([^a-zA-Z0-9 \-\/])/', '', strtolower($string)));
81        $regex  = '/' . $separator . '*' . $separator .'/';
82
83        return preg_replace($regex, $separator, $string);
84    }
85
86    /**
87     * Convert any links in the string to HTML links.
88     *
89     * @param  string $string
90     * @param  array  $attributes
91     * @return string
92     */
93    public static function createLinks(string $string, array $attributes = []): string
94    {
95        foreach (self::$linksRegex as $regex) {
96            $replacement = $regex['replacement'];
97
98            if (!empty($attributes)) {
99                $attribs = [];
100                foreach ($attributes as $attrib => $value) {
101                    $attribs[] = $attrib . '="' . $value . '"';
102                }
103                $replacement = str_replace('<a ', '<a ' . implode(' ', $attribs) . ' ', $replacement);
104            }
105
106            $string = preg_replace($regex['pattern'], $replacement, $string);
107        }
108
109        return $string;
110    }
111
112    /**
113     * Generate a random string of a predefined length.
114     *
115     * @param  int $length
116     * @param  int $case
117     * @return string
118     */
119    public static function createRandom(int $length, int $case = self::MIXEDCASE): string
120    {
121        $chars    = self::$randomChars;
122        $charsets = [];
123
124        switch ($case) {
125            case 1:
126                unset($chars[1]);
127                break;
128            case 2:
129                unset($chars[0]);
130                break;
131        }
132
133        foreach ($chars as $key => $value) {
134            $charsets[] = str_split($value);
135        }
136
137        return self::generateRandomString($length, $charsets);
138    }
139
140    /**
141     * Generate a random alphanumeric string of a predefined length.
142     *
143     * @param  int $length
144     * @param  int $case
145     * @return string
146     */
147    public static function createRandomAlphaNum(int $length, int $case = self::MIXEDCASE): string
148    {
149        $chars    = self::$randomChars;
150        $charsets = [];
151
152        switch ($case) {
153            case 1:
154                unset($chars[1]);
155                break;
156            case 2:
157                unset($chars[0]);
158                break;
159        }
160        unset($chars[3]);
161
162        foreach ($chars as $key => $value) {
163            $charsets[] = str_split($value);
164        }
165
166        return self::generateRandomString($length, $charsets);
167    }
168
169    /**
170     * Generate a random alphabetical string of a predefined length.
171     *
172     * @param  int $length
173     * @param  int $case
174     * @return string
175     */
176    public static function createRandomAlpha(int $length, int $case = self::MIXEDCASE): string
177    {
178        $chars    = self::$randomChars;
179        $charsets = [];
180
181        switch ($case) {
182            case 1:
183                unset($chars[1]);
184                break;
185            case 2:
186                unset($chars[0]);
187                break;
188        }
189        unset($chars[2]);
190        unset($chars[3]);
191
192        foreach ($chars as $key => $value) {
193            $charsets[] = str_split($value);
194        }
195
196        return self::generateRandomString($length, $charsets);
197    }
198
199    /**
200     * Generate a random numeric string of a predefined length.
201     *
202     * @param  int $length
203     * @return string
204     */
205    public static function createRandomNumeric(int $length): string
206    {
207        return self::generateRandomString($length, [str_split(self::$randomChars[2])]);
208    }
209
210    /**
211     * Generate characters based on length and character sets provided
212     *
213     * @param  int   $length
214     * @param  array $charsets
215     * @return string
216     */
217    public static function generateRandomString(int $length, array $charsets): string
218    {
219        $string  = '';
220        $indices = array_keys($charsets);
221
222        for ($i = 0; $i < $length; $i++) {
223            $index    = $indices[rand(0, (count($indices) - 1))];
224            $subIndex = rand(0, (count($charsets[$index]) - 1));
225            $string  .= $charsets[$index][$subIndex];
226        }
227
228        return $string;
229    }
230
231    /**
232     * Strip special characters
233     *
234     * @param  string $string
235     * @param  bool   $alphaNumOnly    No dashes or underscores
236     * @param  bool   $spaces          Allow spaces
237     * @return string
238     */
239    public static function stripSpecialCharacters(string $string, bool $alphaNumOnly = false, bool $spaces = true): string
240    {
241        if ($alphaNumOnly) {
242            $regex = ($spaces) ? "/[^A-Za-z0-9 ]/" : "/[^A-Za-z0-9]/";
243        } else {
244            $regex = ($spaces) ? "/[^A-Za-z0-9_\- ]/" : "/[^A-Za-z0-9_\-]/";
245        }
246        return preg_replace($regex, '', $string);
247    }
248
249    /**
250     * Check if the sources matches the source string(s) in some way - via direct/literal compare or a similar_text() call
251     *
252     * @param  string $string
253     * @param  mixed  $sources
254     * @param  bool   $strict    If the sources are an array of multiple sources, strict = true means all have to match
255     * @param  int    $accuracy  Sets the precentage of accuracy for similar_text()
256     * @return bool
257     */
258    public static function matches(string $string, mixed $sources, bool $strict = false, int $accuracy = 75): bool
259    {
260        $sources = Arr::make($sources);
261
262        if (empty($sources)) {
263            return false;
264        }
265
266        $results = [];
267
268        foreach ($sources as $source) {
269            if (str_contains($source, $string)) {
270                $results[] = true;
271                continue;
272            }
273            similar_text($string, $source, $percent);
274            $results[] = ($percent >= $accuracy);
275        }
276
277        return $strict ? !in_array(false, $results, true) : in_array(true, $results, true);
278    }
279
280    /**
281     * Check if the words in the string can be found within the source string(s), tolerant of extra
282     * words, punctuation, word order, and minor typos (via a per-word similar_text() comparison)
283     *
284     * @param  string $string
285     * @param  mixed  $sources
286     * @param  bool   $strict    If the sources are an array of multiple sources, strict = true means all have to match
287     * @param  int    $accuracy  Sets the percentage of accuracy for both per-word typo tolerance and the overall words-found ratio
288     * @return bool
289     */
290    public static function matchesWords(string $string, mixed $sources, bool $strict = false, int $accuracy = 75): bool
291    {
292        $sources = Arr::make($sources);
293
294        if (empty($sources)) {
295            return false;
296        }
297
298        $results = [];
299
300        foreach ($sources as $source) {
301            $results[] = self::matchesWordsInSource($string, $source, $accuracy);
302        }
303
304        return $strict ? !in_array(false, $results, true) : in_array(true, $results, true);
305    }
306
307    /**
308     * Check if the words in the string can be found within a single source string
309     *
310     * @param  string $string
311     * @param  string $source
312     * @param  int    $accuracy
313     * @return bool
314     */
315    protected static function matchesWordsInSource(string $string, string $source, int $accuracy): bool
316    {
317        if (str_contains($source, $string)) {
318            return true;
319        }
320
321        $stringWords = self::extractWords($string);
322
323        if (empty($stringWords)) {
324            return false;
325        }
326
327        $sourceWords = self::extractWords($source);
328        $matched     = 0;
329
330        foreach ($stringWords as $word) {
331            if (in_array($word, $sourceWords, true)) {
332                $matched++;
333                continue;
334            }
335            foreach ($sourceWords as $sourceWord) {
336                similar_text($word, $sourceWord, $percent);
337                if ($percent >= $accuracy) {
338                    $matched++;
339                    continue 2;
340                }
341            }
342        }
343
344        return (($matched / count($stringWords)) * 100) >= $accuracy;
345    }
346
347    /**
348     * Extract lowercased, punctuation-stripped words from a string
349     *
350     * @param  string $string
351     * @return array
352     */
353    protected static function extractWords(string $string): array
354    {
355        $words = preg_split('/\s+/', strtolower($string), -1, PREG_SPLIT_NO_EMPTY);
356
357        return array_values(array_filter(array_map(
358            fn($word) => preg_replace('/[^\p{L}\p{N}]+/u', '', $word),
359            $words
360        ), fn($word) => $word !== ''));
361    }
362
363    /**
364     * Convert a string from one case to another
365     *
366     * @param string $name
367     * @param array  $arguments
368     * @return string
369     */
370    public static function __callStatic(string $name, array $arguments): string
371    {
372        [$from, $to]   = explode('to', strtolower($name));
373        $string        = $arguments[0] ?? null;
374        $preserveCase  = (array_key_exists(1, $arguments) && is_bool($arguments[1])) ? $arguments[1] : null;
375        $separator     = null;
376        $prevSeparator = null;
377        $result        = null;
378
379        switch ($to) {
380            case 'titlecase':
381            case 'camelcase':
382                $preserveCase = true;
383                break;
384            case 'kebabcase':
385            case 'dash':
386                $separator = '-';
387                if ($preserveCase === null) {
388                    $preserveCase = false;
389                }
390                break;
391            case 'snakecase':
392            case 'underscore':
393                $separator = '_';
394                if ($preserveCase === null) {
395                    $preserveCase = false;
396                }
397                break;
398            case 'namespace':
399                $separator = '\\';
400                if ($preserveCase === null) {
401                    $preserveCase = true;
402                }
403                break;
404            case 'path':
405                $separator = DIRECTORY_SEPARATOR;
406                if ($preserveCase === null) {
407                    $preserveCase = true;
408                }
409                break;
410            case 'uri':
411            case 'url':
412                $separator = '/';
413                if ($preserveCase === null) {
414                    $preserveCase = true;
415                }
416                break;
417        }
418
419        switch ($from) {
420            case 'titlecase':
421            case 'camelcase':
422                $result = self::convertFromCamelCase($string, $separator, $preserveCase);
423                if ($to == 'titlecase') {
424                    $result = ucfirst($result);
425                }
426                if ($to == 'camelcase') {
427                    $result = lcfirst($result);
428                }
429                break;
430            case 'kebabcase':
431            case 'dash':
432                $prevSeparator = '-';
433                break;
434            case 'snakecase':
435            case 'underscore':
436                $prevSeparator = '_';
437                break;
438            case 'namespace':
439                $prevSeparator = '\\';
440                break;
441            case 'path':
442                $prevSeparator = DIRECTORY_SEPARATOR;
443                break;
444            case 'url':
445            case 'uri':
446                $prevSeparator = '/';
447                break;
448        }
449
450        if ($result === null) {
451            switch ($to) {
452                case 'titlecase':
453                    $result = ucfirst(self::convertToCamelCase($string, $prevSeparator));
454                    break;
455                case 'camelcase':
456                    $result = lcfirst(self::convertToCamelCase($string, $prevSeparator));
457                    break;
458                default:
459                    if ($preserveCase) {
460                        $string = implode($prevSeparator, array_map('ucfirst', explode($prevSeparator, $string)));
461                    }
462                    $result = str_replace($prevSeparator, $separator, $string);
463                    if ($preserveCase === false) {
464                        $result = strtolower($result);
465                    }
466            }
467        }
468
469        return $result;
470    }
471
472    /**
473     * Convert a camelCase string using the $separator value passed
474     *
475     * @param string  $string
476     * @param ?string $separator
477     * @param bool    $preserveCase
478     * @return string
479     */
480    public static function convertFromCamelCase(string $string, ?string $separator = null, bool $preserveCase = false): string
481    {
482        $stringAry = str_split($string);
483        $converted = null;
484
485        foreach ($stringAry as $i => $char) {
486            $converted .= ($i == 0) ?
487                $char : ((ctype_upper($char)) ? ($separator . $char) : $char);
488        }
489
490        return ($preserveCase) ? $converted : strtolower($converted);
491    }
492
493    /**
494     * Convert a camelCase string using the $separator value passed
495     *
496     * @param  string  $string
497     * @param  ?string $separator
498     * @return string
499     */
500    public static function convertToCamelCase(string $string, ?string $separator = null): string
501    {
502        if ($separator === null) {
503            $separator = self::detectSeparator($string);
504        }
505        $stringAry = explode($separator, $string);
506        $converted = null;
507
508        foreach ($stringAry as $i => $word) {
509            $converted .= ($i == 0) ? $word : ucfirst($word);
510        }
511
512        return $converted;
513    }
514
515    /**
516     * Attempt to detect separator
517     *
518     * @param  string  $string
519     * @return string
520     */
521    public static function detectSeparator(string $string): string
522    {
523        $separator  = '';
524        $separators = ['-', '_', '\\', '/', DIRECTORY_SEPARATOR];
525
526        foreach ($separators as $s) {
527            if (str_contains($string, $s)) {
528                return $s;
529            }
530        }
531
532        return $separator;
533    }
534
535}
536