Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.47% covered (success)
92.47%
135 / 146
81.82% covered (warning)
81.82%
9 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
StringUtils
92.47% covered (success)
92.47%
135 / 146
81.82% covered (warning)
81.82%
9 / 11
85.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
n/a
0 / 0
n/a
0 / 0
1
 contains
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 endsWith
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 startsWith
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 countCharacterFromStart
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 entropy
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 stringify
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
18
 createDiffMarkup
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
21
 computeLCSDiff
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
12
 intHash
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 isShellSafe
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
6
 intToAlphabet
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Jingga
4 *
5 * PHP Version 8.1
6 *
7 * @package   phpOMS\Utils
8 * @copyright Dennis Eichhorn
9 * @license   OMS License 2.0
10 * @version   1.0.0
11 * @link      https://jingga.app
12 */
13declare(strict_types=1);
14
15namespace phpOMS\Utils;
16
17use phpOMS\Contract\RenderableInterface;
18use phpOMS\Contract\SerializableInterface;
19
20/**
21 * String utils class.
22 *
23 * This class provides static helper functionalities for strings.
24 *
25 * @package phpOMS\Utils
26 * @license OMS License 2.0
27 * @link    https://jingga.app
28 * @since   1.0.0
29 *
30 * @SuppressWarnings(PHPMD.CamelCaseMethodName)
31 */
32final class StringUtils
33{
34    /**
35     * Constructor.
36     *
37     * This class is purely static and is preventing any initialization
38     *
39     * @since 1.0.0
40     * @codeCoverageIgnore
41     */
42    private function __construct()
43    {
44    }
45
46    /**
47     * Check if a string contains any of the provided needles (case sensitive).
48     *
49     * The validation is done case sensitive.
50     *
51     * @param string   $haystack Haystack
52     * @param string[] $needles  Needles to check if any of them are part of the haystack
53     *
54     * @example StringUtils::contains('This string', ['This', 'test']); // true
55     *
56     * @return bool the function returns true if any of the needles is part of the haystack, false otherwise
57     *
58     * @since 1.0.0
59     */
60    public static function contains(string $haystack, array $needles) : bool
61    {
62        foreach ($needles as $needle) {
63            if (\strpos($haystack, $needle) !== false) {
64                return true;
65            }
66        }
67
68        return false;
69    }
70
71    /**
72     * Tests if a string ends with a certain string (case sensitive).
73     *
74     * The validation is done case sensitive. The function takes strings or an array of strings for the validation.
75     * In case of an array the function will test if any of the needles is at the end of the haystack string.
76     *
77     * @param string       $haystack Haystack
78     * @param array|string $needles  needles to check if they are at the end of the haystack
79     *
80     * @example StringUtils::endsWith('Test string', ['test1', 'string']); // true
81     *
82     * @return bool the function returns true if any of the needles is at the end of the haystack, false otherwise
83     *
84     * @since 1.0.0
85     */
86    public static function endsWith(string $haystack, string | array $needles) : bool
87    {
88        if (\is_string($needles)) {
89            $needles = [$needles];
90        }
91
92        foreach ($needles as $needle) {
93            if ($needle === '' || (($temp = \strlen($haystack) - \strlen($needle)) >= 0 && \strpos($haystack, $needle, $temp) !== false)) {
94                return true;
95            }
96        }
97
98        return false;
99    }
100
101    /**
102     * Tests if a string starts with a certain string (case sensitive).
103     *
104     * The validation is done case sensitive. The function takes strings or an array of strings for the validation.
105     * In case of an array the function will test if any of the needles is at the beginning of the haystack string.
106     *
107     * @param string       $haystack Haystack
108     * @param array|string $needles  needles to check if they are at the beginning of the haystack
109     *
110     * @example StringUtils::startsWith('Test string', ['Test', 'something']); // true
111     * @example StringUtils::startsWith('Test string', 'string'); // false
112     * @example StringUtils::startsWith('Test string', 'Test'); // true
113     *
114     * @return bool the function returns true if any of the needles is at the beginning of the haystack, false otherwise
115     *
116     * @since 1.0.0
117     */
118    public static function startsWith(string $haystack, string | array $needles) : bool
119    {
120        if (\is_string($needles)) {
121            $needles = [$needles];
122        }
123
124        foreach ($needles as $needle) {
125            if ($needle === '' || \strrpos($haystack, $needle, -\strlen($haystack)) !== false) {
126                return true;
127            }
128        }
129
130        return false;
131    }
132
133    /**
134     * Count occurences of character at the beginning of a string.
135     *
136     * @param string $string    string to analyze
137     * @param string $character character to count at the beginning of the string
138     *
139     * @example StringUtils::countCharacterFromStart('    Test string', ' '); // 4
140     * @example StringUtils::countCharacterFromStart('    Test string', 's'); // 0
141     *
142     * @return int the amount of repeating occurences at the beginning of the string
143     *
144     * @since 1.0.0
145     */
146    public static function countCharacterFromStart(string $string, string $character) : int
147    {
148        $count  = 0;
149        $length = \strlen($string);
150
151        for ($i = 0; $i < $length; ++$i) {
152            if ($string[$i] !== $character) {
153                break;
154            }
155
156            ++$count;
157        }
158
159        return $count;
160    }
161
162    /**
163     * Calculate string entropy
164     *
165     * @param string $value string to analyze
166     *
167     * @return float
168     *
169     * @since 1.0.0
170     */
171    public static function entropy(string $value) : float
172    {
173        $entropy = 0.0;
174        $size    = \strlen($value);
175
176        /** @var array $countChars */
177        $countChars = \count_chars($value, 1);
178
179        /** @var int $v */
180        foreach ($countChars as $v) {
181            $p        = $v / $size;
182            $entropy -= $p * \log($p) / \log(2);
183        }
184
185        return $entropy;
186    }
187
188    /**
189     * Turn value into string
190     *
191     * @param mixed $element value to stringify
192     * @param mixed $option  Stringify option
193     *
194     * @return null|string
195     *
196     * @since 1.0.0
197     */
198    public static function stringify(mixed $element, mixed $option = null) : ?string
199    {
200        if ($element instanceof \JsonSerializable || \is_array($element)) {
201            if ($option !== null && !\is_int($option)) {
202                return null;
203            }
204
205            $encoded = \json_encode($element, $option !== null ? $option : 0);
206
207            return $encoded ? $encoded : null;
208        } elseif ($element instanceof SerializableInterface) {
209            return $element->serialize();
210        } elseif (\is_string($element)) {
211            return $element;
212        } elseif (\is_int($element) || \is_float($element)) {
213            return (string) $element;
214        } elseif (\is_bool($element)) {
215            return $element ? '1' : '0';
216        } elseif ($element === null) {
217            return null;
218        } elseif ($element instanceof \DateTimeInterface) {
219            return $element->format('Y-m-d H:i:s');
220        } elseif ($element instanceof RenderableInterface) {
221            return $element->render();
222        } elseif (\is_object($element) && \method_exists($element, '__toString')) {
223            return $element->__toString();
224        }
225
226        return null;
227    }
228
229    /**
230     * Create string difference markup
231     *
232     * @param string $old   Old strings
233     * @param string $new   New strings
234     * @param string $delim Delim (e.g '' = compare by character, ' ' = compare by words)
235     *
236     * @return string Markup using <del> and <ins> tags
237     *
238     * @since 1.0.0
239     */
240    public static function createDiffMarkup(string $old, string $new, string $delim = '') : string
241    {
242        $splitOld = empty($delim) ? \str_split($old) : \explode($delim, $old);
243        $splitNew = empty($delim) ? \str_split($new) : \explode($delim, $new);
244
245        if ($splitOld === false
246            || (empty($old) && !empty($new))
247            || (!empty($delim) && \count($splitOld) === 1 && $splitOld[0] === '')
248        ) {
249            return '<ins>' . $new . '</ins>';
250        }
251
252        if ($splitNew === false
253            || (!empty($old) && empty($new))
254            || (!empty($delim) && \count($splitNew) === 1 && $splitNew[0] === '')
255        ) {
256            return '<del>' . $old . '</del>';
257        }
258
259        $diff = self::computeLCSDiff($splitOld, $splitNew);
260
261        $n      = \count($diff['values']);
262        $result = '';
263        $mc     = 0;
264
265        for ($i = 0; $i < $n; ++$i) {
266            $mc = $diff['mask'][$i];
267
268            if ($mc !== 0) {
269                switch ($mc) {
270                    case -1:
271                        $result .= '<del>' . $diff['values'][$i] . '</del>' . $delim;
272                        break;
273                    case 1:
274                        $result .= '<ins>' . $diff['values'][$i] . '</ins>' . $delim;
275                        break;
276                }
277            } else {
278                $result .= $diff['values'][$i] . $delim;
279            }
280        }
281
282        $result = \rtrim($result, $delim);
283
284        switch ($mc) {
285            case -1:
286                $result .= '</del>';
287                break;
288            case 1:
289                $result .= '</ins>';
290                break;
291        }
292
293        // @todo: This should not be necessary but the algorithm above allows for weird combinations.
294        return \str_replace(
295            ['</del></del>', '</ins></ins>', '<ins></ins>', '<del></del>', '</ins><ins>', '</del><del>', '</ins> <del>', '</del> <ins>'],
296            ['</del>', '</ins>', '', '', '', '', '</ins><del>', '</del><ins>'],
297            $result
298        );
299    }
300
301    /**
302     * Create LCS diff masks
303     *
304     * @param string[] $from From/old strings
305     * @param string[] $to   To/new strings
306     *
307     * @return array
308     *
309     * @throws \Exception This exception is thrown if one of the parameters is empty
310     *
311     * @since 1.0.0
312     */
313    private static function computeLCSDiff(array $from, array $to) : array
314    {
315        $diffValues = [];
316        $diffMask   = [];
317
318        $dm = [];
319        $n1 = \count($from);
320        $n2 = \count($to);
321
322        for ($j = -1; $j < $n2; ++$j) {
323            $dm[-1][$j] = 0;
324        }
325
326        for ($i = -1; $i < $n1; ++$i) {
327            $dm[$i][-1] = 0;
328        }
329
330        for ($i = 0; $i < $n1; ++$i) {
331            for ($j = 0; $j < $n2; ++$j) {
332                $dm[$i][$j] = $from[$i] === $to[$j]
333                    ? $dm[$i - 1][$j - 1] + 1
334                    : \max($dm[$i - 1][$j], $dm[$i][$j - 1]);
335            }
336        }
337
338        $i = $n1 - 1;
339        $j = $n2 - 1;
340        while ($i > -1 || $j > -1) {
341            if ($j > -1 && $dm[$i][$j - 1] === $dm[$i][$j]) {
342                $diffValues[] = $to[$j];
343                $diffMask[]   = 1;
344                --$j;
345
346                continue;
347            }
348
349            if ($i > -1 && $dm[$i - 1][$j] === $dm[$i][$j]) {
350                $diffValues[] = $from[$i];
351                $diffMask[]   = -1;
352                --$i;
353
354                continue;
355            }
356
357            $diffValues[] = $from[$i];
358            $diffMask[]   = 0;
359            --$i;
360            --$j;
361        }
362
363        $diffValues = \array_reverse($diffValues);
364        $diffMask   = \array_reverse($diffMask);
365
366        return ['values' => $diffValues, 'mask' => $diffMask];
367    }
368
369    /**
370     * Create a int hash from a string
371     *
372     * @param string $str String to hash
373     *
374     * @return int
375     *
376     * @since 1.0.0
377     */
378    public static function intHash(string $str) : int
379    {
380        $res = 0;
381        $pow = 1;
382        $len = \strlen($str);
383
384        for ($i = 0; $i < $len; ++$i) {
385            $res = ($res + (\ord($str[$i]) - \ord('a') + 1) * $pow) % (1e9 + 9);
386            $pow = ($pow * 31) % (1e9 + 9);
387        }
388
389        return (int) $res;
390    }
391
392    /**
393     * Fix CVE-2016-10033 and CVE-2016-10045 by disallowing potentially unsafe shell characters.
394     *
395     * @param string $string String to check
396     *
397     * @return bool
398     *
399     * @since 1.0.0
400     */
401    public static function isShellSafe(string $string) : bool
402    {
403        if (\escapeshellcmd($string) !== $string
404            || !\in_array(\escapeshellarg($string), ["'{$string}'", "\"{$string}\""])
405        ) {
406            return false;
407        }
408
409        $length = \strlen($string);
410
411        for ($i = 0; $i < $length; ++$i) {
412            $c = $string[$i];
413
414            if (!\ctype_alnum($c) && \strpos('@_-.', $c) === false) {
415                return false;
416            }
417        }
418
419        return true;
420    }
421
422    /**
423     * Turn ints into spreadsheet column names
424     *
425     * @param int $num Column number (1 = A)
426     *
427     * @return string
428     *
429     * @since 1.0.0
430     */
431    public static function intToAlphabet(int $num) : string
432    {
433        if ($num < 0) {
434            return '';
435        }
436
437        $result = '';
438        while ($num >= 0) {
439            $remainder = $num % 26;
440            $result    = \chr(64 + $remainder) . $result;
441
442            if ($num < 26) {
443                break;
444            }
445
446            $num = (int) \floor($num / 26);
447        }
448
449        return $result;
450    }
451}