@@ -16,7 +16,7 @@ discard block |
||
16 | 16 | use Symfony\Contracts\Translation\LocaleAwareInterface; |
17 | 17 | |
18 | 18 | if (!interface_exists(LocaleAwareInterface::class)) { |
19 | - throw new \LogicException('You cannot use the "Symfony\Component\String\Slugger\AsciiSlugger" as the "symfony/translation-contracts" package is not installed. Try running "composer require symfony/translation-contracts".'); |
|
19 | + throw new \LogicException('You cannot use the "Symfony\Component\String\Slugger\AsciiSlugger" as the "symfony/translation-contracts" package is not installed. Try running "composer require symfony/translation-contracts".'); |
|
20 | 20 | } |
21 | 21 | |
22 | 22 | /** |
@@ -24,160 +24,160 @@ discard block |
||
24 | 24 | */ |
25 | 25 | class AsciiSlugger implements SluggerInterface, LocaleAwareInterface |
26 | 26 | { |
27 | - private const LOCALE_TO_TRANSLITERATOR_ID = [ |
|
28 | - 'am' => 'Amharic-Latin', |
|
29 | - 'ar' => 'Arabic-Latin', |
|
30 | - 'az' => 'Azerbaijani-Latin', |
|
31 | - 'be' => 'Belarusian-Latin', |
|
32 | - 'bg' => 'Bulgarian-Latin', |
|
33 | - 'bn' => 'Bengali-Latin', |
|
34 | - 'de' => 'de-ASCII', |
|
35 | - 'el' => 'Greek-Latin', |
|
36 | - 'fa' => 'Persian-Latin', |
|
37 | - 'he' => 'Hebrew-Latin', |
|
38 | - 'hy' => 'Armenian-Latin', |
|
39 | - 'ka' => 'Georgian-Latin', |
|
40 | - 'kk' => 'Kazakh-Latin', |
|
41 | - 'ky' => 'Kirghiz-Latin', |
|
42 | - 'ko' => 'Korean-Latin', |
|
43 | - 'mk' => 'Macedonian-Latin', |
|
44 | - 'mn' => 'Mongolian-Latin', |
|
45 | - 'or' => 'Oriya-Latin', |
|
46 | - 'ps' => 'Pashto-Latin', |
|
47 | - 'ru' => 'Russian-Latin', |
|
48 | - 'sr' => 'Serbian-Latin', |
|
49 | - 'sr_Cyrl' => 'Serbian-Latin', |
|
50 | - 'th' => 'Thai-Latin', |
|
51 | - 'tk' => 'Turkmen-Latin', |
|
52 | - 'uk' => 'Ukrainian-Latin', |
|
53 | - 'uz' => 'Uzbek-Latin', |
|
54 | - 'zh' => 'Han-Latin', |
|
55 | - ]; |
|
56 | - |
|
57 | - private $defaultLocale; |
|
58 | - private $symbolsMap = [ |
|
59 | - 'en' => ['@' => 'at', '&' => 'and'], |
|
60 | - ]; |
|
61 | - |
|
62 | - /** |
|
63 | - * Cache of transliterators per locale. |
|
64 | - * |
|
65 | - * @var \Transliterator[] |
|
66 | - */ |
|
67 | - private $transliterators = []; |
|
68 | - |
|
69 | - /** |
|
70 | - * @param array|\Closure|null $symbolsMap |
|
71 | - */ |
|
72 | - public function __construct(string $defaultLocale = null, $symbolsMap = null) |
|
73 | - { |
|
74 | - if (null !== $symbolsMap && !\is_array($symbolsMap) && !$symbolsMap instanceof \Closure) { |
|
75 | - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be array, Closure or null, "%s" given.', __METHOD__, \gettype($symbolsMap))); |
|
76 | - } |
|
77 | - |
|
78 | - $this->defaultLocale = $defaultLocale; |
|
79 | - $this->symbolsMap = $symbolsMap ?? $this->symbolsMap; |
|
80 | - } |
|
81 | - |
|
82 | - /** |
|
83 | - * {@inheritdoc} |
|
84 | - */ |
|
85 | - public function setLocale($locale) |
|
86 | - { |
|
87 | - $this->defaultLocale = $locale; |
|
88 | - } |
|
89 | - |
|
90 | - /** |
|
91 | - * {@inheritdoc} |
|
92 | - */ |
|
93 | - public function getLocale() |
|
94 | - { |
|
95 | - return $this->defaultLocale; |
|
96 | - } |
|
97 | - |
|
98 | - /** |
|
99 | - * {@inheritdoc} |
|
100 | - */ |
|
101 | - public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString |
|
102 | - { |
|
103 | - $locale = $locale ?? $this->defaultLocale; |
|
104 | - |
|
105 | - $transliterator = []; |
|
106 | - if ($locale && ('de' === $locale || 0 === strpos($locale, 'de_'))) { |
|
107 | - // Use the shortcut for German in UnicodeString::ascii() if possible (faster and no requirement on intl) |
|
108 | - $transliterator = ['de-ASCII']; |
|
109 | - } elseif (\function_exists('transliterator_transliterate') && $locale) { |
|
110 | - $transliterator = (array) $this->createTransliterator($locale); |
|
111 | - } |
|
112 | - |
|
113 | - if ($this->symbolsMap instanceof \Closure) { |
|
114 | - // If the symbols map is passed as a closure, there is no need to fallback to the parent locale |
|
115 | - // as the closure can just provide substitutions for all locales of interest. |
|
116 | - $symbolsMap = $this->symbolsMap; |
|
117 | - array_unshift($transliterator, static function ($s) use ($symbolsMap, $locale) { |
|
118 | - return $symbolsMap($s, $locale); |
|
119 | - }); |
|
120 | - } |
|
121 | - |
|
122 | - $unicodeString = (new UnicodeString($string))->ascii($transliterator); |
|
123 | - |
|
124 | - if (\is_array($this->symbolsMap)) { |
|
125 | - $map = null; |
|
126 | - if (isset($this->symbolsMap[$locale])) { |
|
127 | - $map = $this->symbolsMap[$locale]; |
|
128 | - } else { |
|
129 | - $parent = self::getParentLocale($locale); |
|
130 | - if ($parent && isset($this->symbolsMap[$parent])) { |
|
131 | - $map = $this->symbolsMap[$parent]; |
|
132 | - } |
|
133 | - } |
|
134 | - if ($map) { |
|
135 | - foreach ($map as $char => $replace) { |
|
136 | - $unicodeString = $unicodeString->replace($char, ' '.$replace.' '); |
|
137 | - } |
|
138 | - } |
|
139 | - } |
|
140 | - |
|
141 | - return $unicodeString |
|
142 | - ->replaceMatches('/[^A-Za-z0-9]++/', $separator) |
|
143 | - ->trim($separator) |
|
144 | - ; |
|
145 | - } |
|
146 | - |
|
147 | - private function createTransliterator(string $locale): ?\Transliterator |
|
148 | - { |
|
149 | - if (\array_key_exists($locale, $this->transliterators)) { |
|
150 | - return $this->transliterators[$locale]; |
|
151 | - } |
|
152 | - |
|
153 | - // Exact locale supported, cache and return |
|
154 | - if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$locale] ?? null) { |
|
155 | - return $this->transliterators[$locale] = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); |
|
156 | - } |
|
157 | - |
|
158 | - // Locale not supported and no parent, fallback to any-latin |
|
159 | - if (!$parent = self::getParentLocale($locale)) { |
|
160 | - return $this->transliterators[$locale] = null; |
|
161 | - } |
|
162 | - |
|
163 | - // Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales |
|
164 | - if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) { |
|
165 | - $transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); |
|
166 | - } |
|
167 | - |
|
168 | - return $this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator ?? null; |
|
169 | - } |
|
170 | - |
|
171 | - private static function getParentLocale(?string $locale): ?string |
|
172 | - { |
|
173 | - if (!$locale) { |
|
174 | - return null; |
|
175 | - } |
|
176 | - if (false === $str = strrchr($locale, '_')) { |
|
177 | - // no parent locale |
|
178 | - return null; |
|
179 | - } |
|
180 | - |
|
181 | - return substr($locale, 0, -\strlen($str)); |
|
182 | - } |
|
27 | + private const LOCALE_TO_TRANSLITERATOR_ID = [ |
|
28 | + 'am' => 'Amharic-Latin', |
|
29 | + 'ar' => 'Arabic-Latin', |
|
30 | + 'az' => 'Azerbaijani-Latin', |
|
31 | + 'be' => 'Belarusian-Latin', |
|
32 | + 'bg' => 'Bulgarian-Latin', |
|
33 | + 'bn' => 'Bengali-Latin', |
|
34 | + 'de' => 'de-ASCII', |
|
35 | + 'el' => 'Greek-Latin', |
|
36 | + 'fa' => 'Persian-Latin', |
|
37 | + 'he' => 'Hebrew-Latin', |
|
38 | + 'hy' => 'Armenian-Latin', |
|
39 | + 'ka' => 'Georgian-Latin', |
|
40 | + 'kk' => 'Kazakh-Latin', |
|
41 | + 'ky' => 'Kirghiz-Latin', |
|
42 | + 'ko' => 'Korean-Latin', |
|
43 | + 'mk' => 'Macedonian-Latin', |
|
44 | + 'mn' => 'Mongolian-Latin', |
|
45 | + 'or' => 'Oriya-Latin', |
|
46 | + 'ps' => 'Pashto-Latin', |
|
47 | + 'ru' => 'Russian-Latin', |
|
48 | + 'sr' => 'Serbian-Latin', |
|
49 | + 'sr_Cyrl' => 'Serbian-Latin', |
|
50 | + 'th' => 'Thai-Latin', |
|
51 | + 'tk' => 'Turkmen-Latin', |
|
52 | + 'uk' => 'Ukrainian-Latin', |
|
53 | + 'uz' => 'Uzbek-Latin', |
|
54 | + 'zh' => 'Han-Latin', |
|
55 | + ]; |
|
56 | + |
|
57 | + private $defaultLocale; |
|
58 | + private $symbolsMap = [ |
|
59 | + 'en' => ['@' => 'at', '&' => 'and'], |
|
60 | + ]; |
|
61 | + |
|
62 | + /** |
|
63 | + * Cache of transliterators per locale. |
|
64 | + * |
|
65 | + * @var \Transliterator[] |
|
66 | + */ |
|
67 | + private $transliterators = []; |
|
68 | + |
|
69 | + /** |
|
70 | + * @param array|\Closure|null $symbolsMap |
|
71 | + */ |
|
72 | + public function __construct(string $defaultLocale = null, $symbolsMap = null) |
|
73 | + { |
|
74 | + if (null !== $symbolsMap && !\is_array($symbolsMap) && !$symbolsMap instanceof \Closure) { |
|
75 | + throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be array, Closure or null, "%s" given.', __METHOD__, \gettype($symbolsMap))); |
|
76 | + } |
|
77 | + |
|
78 | + $this->defaultLocale = $defaultLocale; |
|
79 | + $this->symbolsMap = $symbolsMap ?? $this->symbolsMap; |
|
80 | + } |
|
81 | + |
|
82 | + /** |
|
83 | + * {@inheritdoc} |
|
84 | + */ |
|
85 | + public function setLocale($locale) |
|
86 | + { |
|
87 | + $this->defaultLocale = $locale; |
|
88 | + } |
|
89 | + |
|
90 | + /** |
|
91 | + * {@inheritdoc} |
|
92 | + */ |
|
93 | + public function getLocale() |
|
94 | + { |
|
95 | + return $this->defaultLocale; |
|
96 | + } |
|
97 | + |
|
98 | + /** |
|
99 | + * {@inheritdoc} |
|
100 | + */ |
|
101 | + public function slug(string $string, string $separator = '-', string $locale = null): AbstractUnicodeString |
|
102 | + { |
|
103 | + $locale = $locale ?? $this->defaultLocale; |
|
104 | + |
|
105 | + $transliterator = []; |
|
106 | + if ($locale && ('de' === $locale || 0 === strpos($locale, 'de_'))) { |
|
107 | + // Use the shortcut for German in UnicodeString::ascii() if possible (faster and no requirement on intl) |
|
108 | + $transliterator = ['de-ASCII']; |
|
109 | + } elseif (\function_exists('transliterator_transliterate') && $locale) { |
|
110 | + $transliterator = (array) $this->createTransliterator($locale); |
|
111 | + } |
|
112 | + |
|
113 | + if ($this->symbolsMap instanceof \Closure) { |
|
114 | + // If the symbols map is passed as a closure, there is no need to fallback to the parent locale |
|
115 | + // as the closure can just provide substitutions for all locales of interest. |
|
116 | + $symbolsMap = $this->symbolsMap; |
|
117 | + array_unshift($transliterator, static function ($s) use ($symbolsMap, $locale) { |
|
118 | + return $symbolsMap($s, $locale); |
|
119 | + }); |
|
120 | + } |
|
121 | + |
|
122 | + $unicodeString = (new UnicodeString($string))->ascii($transliterator); |
|
123 | + |
|
124 | + if (\is_array($this->symbolsMap)) { |
|
125 | + $map = null; |
|
126 | + if (isset($this->symbolsMap[$locale])) { |
|
127 | + $map = $this->symbolsMap[$locale]; |
|
128 | + } else { |
|
129 | + $parent = self::getParentLocale($locale); |
|
130 | + if ($parent && isset($this->symbolsMap[$parent])) { |
|
131 | + $map = $this->symbolsMap[$parent]; |
|
132 | + } |
|
133 | + } |
|
134 | + if ($map) { |
|
135 | + foreach ($map as $char => $replace) { |
|
136 | + $unicodeString = $unicodeString->replace($char, ' '.$replace.' '); |
|
137 | + } |
|
138 | + } |
|
139 | + } |
|
140 | + |
|
141 | + return $unicodeString |
|
142 | + ->replaceMatches('/[^A-Za-z0-9]++/', $separator) |
|
143 | + ->trim($separator) |
|
144 | + ; |
|
145 | + } |
|
146 | + |
|
147 | + private function createTransliterator(string $locale): ?\Transliterator |
|
148 | + { |
|
149 | + if (\array_key_exists($locale, $this->transliterators)) { |
|
150 | + return $this->transliterators[$locale]; |
|
151 | + } |
|
152 | + |
|
153 | + // Exact locale supported, cache and return |
|
154 | + if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$locale] ?? null) { |
|
155 | + return $this->transliterators[$locale] = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); |
|
156 | + } |
|
157 | + |
|
158 | + // Locale not supported and no parent, fallback to any-latin |
|
159 | + if (!$parent = self::getParentLocale($locale)) { |
|
160 | + return $this->transliterators[$locale] = null; |
|
161 | + } |
|
162 | + |
|
163 | + // Try to use the parent locale (ie. try "de" for "de_AT") and cache both locales |
|
164 | + if ($id = self::LOCALE_TO_TRANSLITERATOR_ID[$parent] ?? null) { |
|
165 | + $transliterator = \Transliterator::create($id.'/BGN') ?? \Transliterator::create($id); |
|
166 | + } |
|
167 | + |
|
168 | + return $this->transliterators[$locale] = $this->transliterators[$parent] = $transliterator ?? null; |
|
169 | + } |
|
170 | + |
|
171 | + private static function getParentLocale(?string $locale): ?string |
|
172 | + { |
|
173 | + if (!$locale) { |
|
174 | + return null; |
|
175 | + } |
|
176 | + if (false === $str = strrchr($locale, '_')) { |
|
177 | + // no parent locale |
|
178 | + return null; |
|
179 | + } |
|
180 | + |
|
181 | + return substr($locale, 0, -\strlen($str)); |
|
182 | + } |
|
183 | 183 | } |
@@ -25,14 +25,14 @@ discard block |
||
25 | 25 | */ |
26 | 26 | class ByteString extends AbstractString |
27 | 27 | { |
28 | - private const ALPHABET_ALPHANUMERIC = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; |
|
28 | + private const ALPHABET_ALPHANUMERIC = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; |
|
29 | 29 | |
30 | - public function __construct(string $string = '') |
|
31 | - { |
|
32 | - $this->string = $string; |
|
33 | - } |
|
30 | + public function __construct(string $string = '') |
|
31 | + { |
|
32 | + $this->string = $string; |
|
33 | + } |
|
34 | 34 | |
35 | - /* |
|
35 | + /* |
|
36 | 36 | * The following method was derived from code of the Hack Standard Library (v4.40 - 2020-05-03) |
37 | 37 | * |
38 | 38 | * https://github.com/hhvm/hsl/blob/80a42c02f036f72a42f0415e80d6b847f4bf62d5/src/random/private.php#L16 |
@@ -42,465 +42,465 @@ discard block |
||
42 | 42 | * Copyright (c) 2004-2020, Facebook, Inc. (https://www.facebook.com/) |
43 | 43 | */ |
44 | 44 | |
45 | - public static function fromRandom(int $length = 16, string $alphabet = null): self |
|
46 | - { |
|
47 | - if ($length <= 0) { |
|
48 | - throw new InvalidArgumentException(sprintf('A strictly positive length is expected, "%d" given.', $length)); |
|
49 | - } |
|
50 | - |
|
51 | - $alphabet = $alphabet ?? self::ALPHABET_ALPHANUMERIC; |
|
52 | - $alphabetSize = \strlen($alphabet); |
|
53 | - $bits = (int) ceil(log($alphabetSize, 2.0)); |
|
54 | - if ($bits <= 0 || $bits > 56) { |
|
55 | - throw new InvalidArgumentException('The length of the alphabet must in the [2^1, 2^56] range.'); |
|
56 | - } |
|
57 | - |
|
58 | - $ret = ''; |
|
59 | - while ($length > 0) { |
|
60 | - $urandomLength = (int) ceil(2 * $length * $bits / 8.0); |
|
61 | - $data = random_bytes($urandomLength); |
|
62 | - $unpackedData = 0; |
|
63 | - $unpackedBits = 0; |
|
64 | - for ($i = 0; $i < $urandomLength && $length > 0; ++$i) { |
|
65 | - // Unpack 8 bits |
|
66 | - $unpackedData = ($unpackedData << 8) | \ord($data[$i]); |
|
67 | - $unpackedBits += 8; |
|
68 | - |
|
69 | - // While we have enough bits to select a character from the alphabet, keep |
|
70 | - // consuming the random data |
|
71 | - for (; $unpackedBits >= $bits && $length > 0; $unpackedBits -= $bits) { |
|
72 | - $index = ($unpackedData & ((1 << $bits) - 1)); |
|
73 | - $unpackedData >>= $bits; |
|
74 | - // Unfortunately, the alphabet size is not necessarily a power of two. |
|
75 | - // Worst case, it is 2^k + 1, which means we need (k+1) bits and we |
|
76 | - // have around a 50% chance of missing as k gets larger |
|
77 | - if ($index < $alphabetSize) { |
|
78 | - $ret .= $alphabet[$index]; |
|
79 | - --$length; |
|
80 | - } |
|
81 | - } |
|
82 | - } |
|
83 | - } |
|
84 | - |
|
85 | - return new static($ret); |
|
86 | - } |
|
87 | - |
|
88 | - public function bytesAt(int $offset): array |
|
89 | - { |
|
90 | - $str = $this->string[$offset] ?? ''; |
|
91 | - |
|
92 | - return '' === $str ? [] : [\ord($str)]; |
|
93 | - } |
|
94 | - |
|
95 | - public function append(string ...$suffix): parent |
|
96 | - { |
|
97 | - $str = clone $this; |
|
98 | - $str->string .= 1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix); |
|
99 | - |
|
100 | - return $str; |
|
101 | - } |
|
102 | - |
|
103 | - public function camel(): parent |
|
104 | - { |
|
105 | - $str = clone $this; |
|
106 | - $str->string = lcfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $this->string)))); |
|
107 | - |
|
108 | - return $str; |
|
109 | - } |
|
110 | - |
|
111 | - public function chunk(int $length = 1): array |
|
112 | - { |
|
113 | - if (1 > $length) { |
|
114 | - throw new InvalidArgumentException('The chunk length must be greater than zero.'); |
|
115 | - } |
|
116 | - |
|
117 | - if ('' === $this->string) { |
|
118 | - return []; |
|
119 | - } |
|
120 | - |
|
121 | - $str = clone $this; |
|
122 | - $chunks = []; |
|
123 | - |
|
124 | - foreach (str_split($this->string, $length) as $chunk) { |
|
125 | - $str->string = $chunk; |
|
126 | - $chunks[] = clone $str; |
|
127 | - } |
|
128 | - |
|
129 | - return $chunks; |
|
130 | - } |
|
131 | - |
|
132 | - public function endsWith($suffix): bool |
|
133 | - { |
|
134 | - if ($suffix instanceof parent) { |
|
135 | - $suffix = $suffix->string; |
|
136 | - } elseif (\is_array($suffix) || $suffix instanceof \Traversable) { |
|
137 | - return parent::endsWith($suffix); |
|
138 | - } else { |
|
139 | - $suffix = (string) $suffix; |
|
140 | - } |
|
141 | - |
|
142 | - return '' !== $suffix && \strlen($this->string) >= \strlen($suffix) && 0 === substr_compare($this->string, $suffix, -\strlen($suffix), null, $this->ignoreCase); |
|
143 | - } |
|
144 | - |
|
145 | - public function equalsTo($string): bool |
|
146 | - { |
|
147 | - if ($string instanceof parent) { |
|
148 | - $string = $string->string; |
|
149 | - } elseif (\is_array($string) || $string instanceof \Traversable) { |
|
150 | - return parent::equalsTo($string); |
|
151 | - } else { |
|
152 | - $string = (string) $string; |
|
153 | - } |
|
154 | - |
|
155 | - if ('' !== $string && $this->ignoreCase) { |
|
156 | - return 0 === strcasecmp($string, $this->string); |
|
157 | - } |
|
158 | - |
|
159 | - return $string === $this->string; |
|
160 | - } |
|
161 | - |
|
162 | - public function folded(): parent |
|
163 | - { |
|
164 | - $str = clone $this; |
|
165 | - $str->string = strtolower($str->string); |
|
166 | - |
|
167 | - return $str; |
|
168 | - } |
|
169 | - |
|
170 | - public function indexOf($needle, int $offset = 0): ?int |
|
171 | - { |
|
172 | - if ($needle instanceof parent) { |
|
173 | - $needle = $needle->string; |
|
174 | - } elseif (\is_array($needle) || $needle instanceof \Traversable) { |
|
175 | - return parent::indexOf($needle, $offset); |
|
176 | - } else { |
|
177 | - $needle = (string) $needle; |
|
178 | - } |
|
179 | - |
|
180 | - if ('' === $needle) { |
|
181 | - return null; |
|
182 | - } |
|
183 | - |
|
184 | - $i = $this->ignoreCase ? stripos($this->string, $needle, $offset) : strpos($this->string, $needle, $offset); |
|
185 | - |
|
186 | - return false === $i ? null : $i; |
|
187 | - } |
|
188 | - |
|
189 | - public function indexOfLast($needle, int $offset = 0): ?int |
|
190 | - { |
|
191 | - if ($needle instanceof parent) { |
|
192 | - $needle = $needle->string; |
|
193 | - } elseif (\is_array($needle) || $needle instanceof \Traversable) { |
|
194 | - return parent::indexOfLast($needle, $offset); |
|
195 | - } else { |
|
196 | - $needle = (string) $needle; |
|
197 | - } |
|
198 | - |
|
199 | - if ('' === $needle) { |
|
200 | - return null; |
|
201 | - } |
|
202 | - |
|
203 | - $i = $this->ignoreCase ? strripos($this->string, $needle, $offset) : strrpos($this->string, $needle, $offset); |
|
204 | - |
|
205 | - return false === $i ? null : $i; |
|
206 | - } |
|
207 | - |
|
208 | - public function isUtf8(): bool |
|
209 | - { |
|
210 | - return '' === $this->string || preg_match('//u', $this->string); |
|
211 | - } |
|
212 | - |
|
213 | - public function join(array $strings, string $lastGlue = null): parent |
|
214 | - { |
|
215 | - $str = clone $this; |
|
216 | - |
|
217 | - $tail = null !== $lastGlue && 1 < \count($strings) ? $lastGlue.array_pop($strings) : ''; |
|
218 | - $str->string = implode($this->string, $strings).$tail; |
|
219 | - |
|
220 | - return $str; |
|
221 | - } |
|
222 | - |
|
223 | - public function length(): int |
|
224 | - { |
|
225 | - return \strlen($this->string); |
|
226 | - } |
|
227 | - |
|
228 | - public function lower(): parent |
|
229 | - { |
|
230 | - $str = clone $this; |
|
231 | - $str->string = strtolower($str->string); |
|
232 | - |
|
233 | - return $str; |
|
234 | - } |
|
235 | - |
|
236 | - public function match(string $regexp, int $flags = 0, int $offset = 0): array |
|
237 | - { |
|
238 | - $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match'; |
|
239 | - |
|
240 | - if ($this->ignoreCase) { |
|
241 | - $regexp .= 'i'; |
|
242 | - } |
|
243 | - |
|
244 | - set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
245 | - |
|
246 | - try { |
|
247 | - if (false === $match($regexp, $this->string, $matches, $flags | \PREG_UNMATCHED_AS_NULL, $offset)) { |
|
248 | - $lastError = preg_last_error(); |
|
249 | - |
|
250 | - foreach (get_defined_constants(true)['pcre'] as $k => $v) { |
|
251 | - if ($lastError === $v && '_ERROR' === substr($k, -6)) { |
|
252 | - throw new RuntimeException('Matching failed with '.$k.'.'); |
|
253 | - } |
|
254 | - } |
|
255 | - |
|
256 | - throw new RuntimeException('Matching failed with unknown error code.'); |
|
257 | - } |
|
258 | - } finally { |
|
259 | - restore_error_handler(); |
|
260 | - } |
|
261 | - |
|
262 | - return $matches; |
|
263 | - } |
|
264 | - |
|
265 | - public function padBoth(int $length, string $padStr = ' '): parent |
|
266 | - { |
|
267 | - $str = clone $this; |
|
268 | - $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_BOTH); |
|
269 | - |
|
270 | - return $str; |
|
271 | - } |
|
272 | - |
|
273 | - public function padEnd(int $length, string $padStr = ' '): parent |
|
274 | - { |
|
275 | - $str = clone $this; |
|
276 | - $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_RIGHT); |
|
277 | - |
|
278 | - return $str; |
|
279 | - } |
|
280 | - |
|
281 | - public function padStart(int $length, string $padStr = ' '): parent |
|
282 | - { |
|
283 | - $str = clone $this; |
|
284 | - $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_LEFT); |
|
285 | - |
|
286 | - return $str; |
|
287 | - } |
|
288 | - |
|
289 | - public function prepend(string ...$prefix): parent |
|
290 | - { |
|
291 | - $str = clone $this; |
|
292 | - $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$str->string; |
|
293 | - |
|
294 | - return $str; |
|
295 | - } |
|
296 | - |
|
297 | - public function replace(string $from, string $to): parent |
|
298 | - { |
|
299 | - $str = clone $this; |
|
300 | - |
|
301 | - if ('' !== $from) { |
|
302 | - $str->string = $this->ignoreCase ? str_ireplace($from, $to, $this->string) : str_replace($from, $to, $this->string); |
|
303 | - } |
|
304 | - |
|
305 | - return $str; |
|
306 | - } |
|
307 | - |
|
308 | - public function replaceMatches(string $fromRegexp, $to): parent |
|
309 | - { |
|
310 | - if ($this->ignoreCase) { |
|
311 | - $fromRegexp .= 'i'; |
|
312 | - } |
|
313 | - |
|
314 | - if (\is_array($to)) { |
|
315 | - if (!\is_callable($to)) { |
|
316 | - throw new \TypeError(sprintf('Argument 2 passed to "%s::replaceMatches()" must be callable, array given.', static::class)); |
|
317 | - } |
|
318 | - |
|
319 | - $replace = 'preg_replace_callback'; |
|
320 | - } else { |
|
321 | - $replace = $to instanceof \Closure ? 'preg_replace_callback' : 'preg_replace'; |
|
322 | - } |
|
323 | - |
|
324 | - set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
325 | - |
|
326 | - try { |
|
327 | - if (null === $string = $replace($fromRegexp, $to, $this->string)) { |
|
328 | - $lastError = preg_last_error(); |
|
329 | - |
|
330 | - foreach (get_defined_constants(true)['pcre'] as $k => $v) { |
|
331 | - if ($lastError === $v && '_ERROR' === substr($k, -6)) { |
|
332 | - throw new RuntimeException('Matching failed with '.$k.'.'); |
|
333 | - } |
|
334 | - } |
|
335 | - |
|
336 | - throw new RuntimeException('Matching failed with unknown error code.'); |
|
337 | - } |
|
338 | - } finally { |
|
339 | - restore_error_handler(); |
|
340 | - } |
|
341 | - |
|
342 | - $str = clone $this; |
|
343 | - $str->string = $string; |
|
344 | - |
|
345 | - return $str; |
|
346 | - } |
|
347 | - |
|
348 | - public function reverse(): parent |
|
349 | - { |
|
350 | - $str = clone $this; |
|
351 | - $str->string = strrev($str->string); |
|
352 | - |
|
353 | - return $str; |
|
354 | - } |
|
355 | - |
|
356 | - public function slice(int $start = 0, int $length = null): parent |
|
357 | - { |
|
358 | - $str = clone $this; |
|
359 | - $str->string = (string) substr($this->string, $start, $length ?? \PHP_INT_MAX); |
|
360 | - |
|
361 | - return $str; |
|
362 | - } |
|
363 | - |
|
364 | - public function snake(): parent |
|
365 | - { |
|
366 | - $str = $this->camel()->title(); |
|
367 | - $str->string = strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $str->string)); |
|
368 | - |
|
369 | - return $str; |
|
370 | - } |
|
371 | - |
|
372 | - public function splice(string $replacement, int $start = 0, int $length = null): parent |
|
373 | - { |
|
374 | - $str = clone $this; |
|
375 | - $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); |
|
376 | - |
|
377 | - return $str; |
|
378 | - } |
|
379 | - |
|
380 | - public function split(string $delimiter, int $limit = null, int $flags = null): array |
|
381 | - { |
|
382 | - if (1 > $limit = $limit ?? \PHP_INT_MAX) { |
|
383 | - throw new InvalidArgumentException('Split limit must be a positive integer.'); |
|
384 | - } |
|
385 | - |
|
386 | - if ('' === $delimiter) { |
|
387 | - throw new InvalidArgumentException('Split delimiter is empty.'); |
|
388 | - } |
|
389 | - |
|
390 | - if (null !== $flags) { |
|
391 | - return parent::split($delimiter, $limit, $flags); |
|
392 | - } |
|
393 | - |
|
394 | - $str = clone $this; |
|
395 | - $chunks = $this->ignoreCase |
|
396 | - ? preg_split('{'.preg_quote($delimiter).'}iD', $this->string, $limit) |
|
397 | - : explode($delimiter, $this->string, $limit); |
|
398 | - |
|
399 | - foreach ($chunks as &$chunk) { |
|
400 | - $str->string = $chunk; |
|
401 | - $chunk = clone $str; |
|
402 | - } |
|
403 | - |
|
404 | - return $chunks; |
|
405 | - } |
|
406 | - |
|
407 | - public function startsWith($prefix): bool |
|
408 | - { |
|
409 | - if ($prefix instanceof parent) { |
|
410 | - $prefix = $prefix->string; |
|
411 | - } elseif (!\is_string($prefix)) { |
|
412 | - return parent::startsWith($prefix); |
|
413 | - } |
|
414 | - |
|
415 | - return '' !== $prefix && 0 === ($this->ignoreCase ? strncasecmp($this->string, $prefix, \strlen($prefix)) : strncmp($this->string, $prefix, \strlen($prefix))); |
|
416 | - } |
|
417 | - |
|
418 | - public function title(bool $allWords = false): parent |
|
419 | - { |
|
420 | - $str = clone $this; |
|
421 | - $str->string = $allWords ? ucwords($str->string) : ucfirst($str->string); |
|
422 | - |
|
423 | - return $str; |
|
424 | - } |
|
425 | - |
|
426 | - public function toUnicodeString(string $fromEncoding = null): UnicodeString |
|
427 | - { |
|
428 | - return new UnicodeString($this->toCodePointString($fromEncoding)->string); |
|
429 | - } |
|
430 | - |
|
431 | - public function toCodePointString(string $fromEncoding = null): CodePointString |
|
432 | - { |
|
433 | - $u = new CodePointString(); |
|
434 | - |
|
435 | - if (\in_array($fromEncoding, [null, 'utf8', 'utf-8', 'UTF8', 'UTF-8'], true) && preg_match('//u', $this->string)) { |
|
436 | - $u->string = $this->string; |
|
437 | - |
|
438 | - return $u; |
|
439 | - } |
|
45 | + public static function fromRandom(int $length = 16, string $alphabet = null): self |
|
46 | + { |
|
47 | + if ($length <= 0) { |
|
48 | + throw new InvalidArgumentException(sprintf('A strictly positive length is expected, "%d" given.', $length)); |
|
49 | + } |
|
50 | + |
|
51 | + $alphabet = $alphabet ?? self::ALPHABET_ALPHANUMERIC; |
|
52 | + $alphabetSize = \strlen($alphabet); |
|
53 | + $bits = (int) ceil(log($alphabetSize, 2.0)); |
|
54 | + if ($bits <= 0 || $bits > 56) { |
|
55 | + throw new InvalidArgumentException('The length of the alphabet must in the [2^1, 2^56] range.'); |
|
56 | + } |
|
57 | + |
|
58 | + $ret = ''; |
|
59 | + while ($length > 0) { |
|
60 | + $urandomLength = (int) ceil(2 * $length * $bits / 8.0); |
|
61 | + $data = random_bytes($urandomLength); |
|
62 | + $unpackedData = 0; |
|
63 | + $unpackedBits = 0; |
|
64 | + for ($i = 0; $i < $urandomLength && $length > 0; ++$i) { |
|
65 | + // Unpack 8 bits |
|
66 | + $unpackedData = ($unpackedData << 8) | \ord($data[$i]); |
|
67 | + $unpackedBits += 8; |
|
68 | + |
|
69 | + // While we have enough bits to select a character from the alphabet, keep |
|
70 | + // consuming the random data |
|
71 | + for (; $unpackedBits >= $bits && $length > 0; $unpackedBits -= $bits) { |
|
72 | + $index = ($unpackedData & ((1 << $bits) - 1)); |
|
73 | + $unpackedData >>= $bits; |
|
74 | + // Unfortunately, the alphabet size is not necessarily a power of two. |
|
75 | + // Worst case, it is 2^k + 1, which means we need (k+1) bits and we |
|
76 | + // have around a 50% chance of missing as k gets larger |
|
77 | + if ($index < $alphabetSize) { |
|
78 | + $ret .= $alphabet[$index]; |
|
79 | + --$length; |
|
80 | + } |
|
81 | + } |
|
82 | + } |
|
83 | + } |
|
84 | + |
|
85 | + return new static($ret); |
|
86 | + } |
|
87 | + |
|
88 | + public function bytesAt(int $offset): array |
|
89 | + { |
|
90 | + $str = $this->string[$offset] ?? ''; |
|
91 | + |
|
92 | + return '' === $str ? [] : [\ord($str)]; |
|
93 | + } |
|
94 | + |
|
95 | + public function append(string ...$suffix): parent |
|
96 | + { |
|
97 | + $str = clone $this; |
|
98 | + $str->string .= 1 >= \count($suffix) ? ($suffix[0] ?? '') : implode('', $suffix); |
|
99 | + |
|
100 | + return $str; |
|
101 | + } |
|
102 | + |
|
103 | + public function camel(): parent |
|
104 | + { |
|
105 | + $str = clone $this; |
|
106 | + $str->string = lcfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $this->string)))); |
|
107 | + |
|
108 | + return $str; |
|
109 | + } |
|
110 | + |
|
111 | + public function chunk(int $length = 1): array |
|
112 | + { |
|
113 | + if (1 > $length) { |
|
114 | + throw new InvalidArgumentException('The chunk length must be greater than zero.'); |
|
115 | + } |
|
116 | + |
|
117 | + if ('' === $this->string) { |
|
118 | + return []; |
|
119 | + } |
|
120 | + |
|
121 | + $str = clone $this; |
|
122 | + $chunks = []; |
|
123 | + |
|
124 | + foreach (str_split($this->string, $length) as $chunk) { |
|
125 | + $str->string = $chunk; |
|
126 | + $chunks[] = clone $str; |
|
127 | + } |
|
128 | + |
|
129 | + return $chunks; |
|
130 | + } |
|
131 | + |
|
132 | + public function endsWith($suffix): bool |
|
133 | + { |
|
134 | + if ($suffix instanceof parent) { |
|
135 | + $suffix = $suffix->string; |
|
136 | + } elseif (\is_array($suffix) || $suffix instanceof \Traversable) { |
|
137 | + return parent::endsWith($suffix); |
|
138 | + } else { |
|
139 | + $suffix = (string) $suffix; |
|
140 | + } |
|
141 | + |
|
142 | + return '' !== $suffix && \strlen($this->string) >= \strlen($suffix) && 0 === substr_compare($this->string, $suffix, -\strlen($suffix), null, $this->ignoreCase); |
|
143 | + } |
|
144 | + |
|
145 | + public function equalsTo($string): bool |
|
146 | + { |
|
147 | + if ($string instanceof parent) { |
|
148 | + $string = $string->string; |
|
149 | + } elseif (\is_array($string) || $string instanceof \Traversable) { |
|
150 | + return parent::equalsTo($string); |
|
151 | + } else { |
|
152 | + $string = (string) $string; |
|
153 | + } |
|
154 | + |
|
155 | + if ('' !== $string && $this->ignoreCase) { |
|
156 | + return 0 === strcasecmp($string, $this->string); |
|
157 | + } |
|
158 | + |
|
159 | + return $string === $this->string; |
|
160 | + } |
|
161 | + |
|
162 | + public function folded(): parent |
|
163 | + { |
|
164 | + $str = clone $this; |
|
165 | + $str->string = strtolower($str->string); |
|
166 | + |
|
167 | + return $str; |
|
168 | + } |
|
169 | + |
|
170 | + public function indexOf($needle, int $offset = 0): ?int |
|
171 | + { |
|
172 | + if ($needle instanceof parent) { |
|
173 | + $needle = $needle->string; |
|
174 | + } elseif (\is_array($needle) || $needle instanceof \Traversable) { |
|
175 | + return parent::indexOf($needle, $offset); |
|
176 | + } else { |
|
177 | + $needle = (string) $needle; |
|
178 | + } |
|
179 | + |
|
180 | + if ('' === $needle) { |
|
181 | + return null; |
|
182 | + } |
|
183 | + |
|
184 | + $i = $this->ignoreCase ? stripos($this->string, $needle, $offset) : strpos($this->string, $needle, $offset); |
|
185 | + |
|
186 | + return false === $i ? null : $i; |
|
187 | + } |
|
188 | + |
|
189 | + public function indexOfLast($needle, int $offset = 0): ?int |
|
190 | + { |
|
191 | + if ($needle instanceof parent) { |
|
192 | + $needle = $needle->string; |
|
193 | + } elseif (\is_array($needle) || $needle instanceof \Traversable) { |
|
194 | + return parent::indexOfLast($needle, $offset); |
|
195 | + } else { |
|
196 | + $needle = (string) $needle; |
|
197 | + } |
|
198 | + |
|
199 | + if ('' === $needle) { |
|
200 | + return null; |
|
201 | + } |
|
202 | + |
|
203 | + $i = $this->ignoreCase ? strripos($this->string, $needle, $offset) : strrpos($this->string, $needle, $offset); |
|
204 | + |
|
205 | + return false === $i ? null : $i; |
|
206 | + } |
|
207 | + |
|
208 | + public function isUtf8(): bool |
|
209 | + { |
|
210 | + return '' === $this->string || preg_match('//u', $this->string); |
|
211 | + } |
|
212 | + |
|
213 | + public function join(array $strings, string $lastGlue = null): parent |
|
214 | + { |
|
215 | + $str = clone $this; |
|
216 | + |
|
217 | + $tail = null !== $lastGlue && 1 < \count($strings) ? $lastGlue.array_pop($strings) : ''; |
|
218 | + $str->string = implode($this->string, $strings).$tail; |
|
219 | + |
|
220 | + return $str; |
|
221 | + } |
|
222 | + |
|
223 | + public function length(): int |
|
224 | + { |
|
225 | + return \strlen($this->string); |
|
226 | + } |
|
227 | + |
|
228 | + public function lower(): parent |
|
229 | + { |
|
230 | + $str = clone $this; |
|
231 | + $str->string = strtolower($str->string); |
|
232 | + |
|
233 | + return $str; |
|
234 | + } |
|
235 | + |
|
236 | + public function match(string $regexp, int $flags = 0, int $offset = 0): array |
|
237 | + { |
|
238 | + $match = ((\PREG_PATTERN_ORDER | \PREG_SET_ORDER) & $flags) ? 'preg_match_all' : 'preg_match'; |
|
239 | + |
|
240 | + if ($this->ignoreCase) { |
|
241 | + $regexp .= 'i'; |
|
242 | + } |
|
243 | + |
|
244 | + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
245 | + |
|
246 | + try { |
|
247 | + if (false === $match($regexp, $this->string, $matches, $flags | \PREG_UNMATCHED_AS_NULL, $offset)) { |
|
248 | + $lastError = preg_last_error(); |
|
249 | + |
|
250 | + foreach (get_defined_constants(true)['pcre'] as $k => $v) { |
|
251 | + if ($lastError === $v && '_ERROR' === substr($k, -6)) { |
|
252 | + throw new RuntimeException('Matching failed with '.$k.'.'); |
|
253 | + } |
|
254 | + } |
|
255 | + |
|
256 | + throw new RuntimeException('Matching failed with unknown error code.'); |
|
257 | + } |
|
258 | + } finally { |
|
259 | + restore_error_handler(); |
|
260 | + } |
|
261 | + |
|
262 | + return $matches; |
|
263 | + } |
|
264 | + |
|
265 | + public function padBoth(int $length, string $padStr = ' '): parent |
|
266 | + { |
|
267 | + $str = clone $this; |
|
268 | + $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_BOTH); |
|
269 | + |
|
270 | + return $str; |
|
271 | + } |
|
272 | + |
|
273 | + public function padEnd(int $length, string $padStr = ' '): parent |
|
274 | + { |
|
275 | + $str = clone $this; |
|
276 | + $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_RIGHT); |
|
277 | + |
|
278 | + return $str; |
|
279 | + } |
|
280 | + |
|
281 | + public function padStart(int $length, string $padStr = ' '): parent |
|
282 | + { |
|
283 | + $str = clone $this; |
|
284 | + $str->string = str_pad($this->string, $length, $padStr, \STR_PAD_LEFT); |
|
285 | + |
|
286 | + return $str; |
|
287 | + } |
|
288 | + |
|
289 | + public function prepend(string ...$prefix): parent |
|
290 | + { |
|
291 | + $str = clone $this; |
|
292 | + $str->string = (1 >= \count($prefix) ? ($prefix[0] ?? '') : implode('', $prefix)).$str->string; |
|
293 | + |
|
294 | + return $str; |
|
295 | + } |
|
296 | + |
|
297 | + public function replace(string $from, string $to): parent |
|
298 | + { |
|
299 | + $str = clone $this; |
|
300 | + |
|
301 | + if ('' !== $from) { |
|
302 | + $str->string = $this->ignoreCase ? str_ireplace($from, $to, $this->string) : str_replace($from, $to, $this->string); |
|
303 | + } |
|
304 | + |
|
305 | + return $str; |
|
306 | + } |
|
307 | + |
|
308 | + public function replaceMatches(string $fromRegexp, $to): parent |
|
309 | + { |
|
310 | + if ($this->ignoreCase) { |
|
311 | + $fromRegexp .= 'i'; |
|
312 | + } |
|
313 | + |
|
314 | + if (\is_array($to)) { |
|
315 | + if (!\is_callable($to)) { |
|
316 | + throw new \TypeError(sprintf('Argument 2 passed to "%s::replaceMatches()" must be callable, array given.', static::class)); |
|
317 | + } |
|
318 | + |
|
319 | + $replace = 'preg_replace_callback'; |
|
320 | + } else { |
|
321 | + $replace = $to instanceof \Closure ? 'preg_replace_callback' : 'preg_replace'; |
|
322 | + } |
|
323 | + |
|
324 | + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
325 | + |
|
326 | + try { |
|
327 | + if (null === $string = $replace($fromRegexp, $to, $this->string)) { |
|
328 | + $lastError = preg_last_error(); |
|
329 | + |
|
330 | + foreach (get_defined_constants(true)['pcre'] as $k => $v) { |
|
331 | + if ($lastError === $v && '_ERROR' === substr($k, -6)) { |
|
332 | + throw new RuntimeException('Matching failed with '.$k.'.'); |
|
333 | + } |
|
334 | + } |
|
335 | + |
|
336 | + throw new RuntimeException('Matching failed with unknown error code.'); |
|
337 | + } |
|
338 | + } finally { |
|
339 | + restore_error_handler(); |
|
340 | + } |
|
341 | + |
|
342 | + $str = clone $this; |
|
343 | + $str->string = $string; |
|
344 | + |
|
345 | + return $str; |
|
346 | + } |
|
347 | + |
|
348 | + public function reverse(): parent |
|
349 | + { |
|
350 | + $str = clone $this; |
|
351 | + $str->string = strrev($str->string); |
|
352 | + |
|
353 | + return $str; |
|
354 | + } |
|
355 | + |
|
356 | + public function slice(int $start = 0, int $length = null): parent |
|
357 | + { |
|
358 | + $str = clone $this; |
|
359 | + $str->string = (string) substr($this->string, $start, $length ?? \PHP_INT_MAX); |
|
360 | + |
|
361 | + return $str; |
|
362 | + } |
|
363 | + |
|
364 | + public function snake(): parent |
|
365 | + { |
|
366 | + $str = $this->camel()->title(); |
|
367 | + $str->string = strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1_\2', $str->string)); |
|
368 | + |
|
369 | + return $str; |
|
370 | + } |
|
371 | + |
|
372 | + public function splice(string $replacement, int $start = 0, int $length = null): parent |
|
373 | + { |
|
374 | + $str = clone $this; |
|
375 | + $str->string = substr_replace($this->string, $replacement, $start, $length ?? \PHP_INT_MAX); |
|
376 | + |
|
377 | + return $str; |
|
378 | + } |
|
379 | + |
|
380 | + public function split(string $delimiter, int $limit = null, int $flags = null): array |
|
381 | + { |
|
382 | + if (1 > $limit = $limit ?? \PHP_INT_MAX) { |
|
383 | + throw new InvalidArgumentException('Split limit must be a positive integer.'); |
|
384 | + } |
|
385 | + |
|
386 | + if ('' === $delimiter) { |
|
387 | + throw new InvalidArgumentException('Split delimiter is empty.'); |
|
388 | + } |
|
389 | + |
|
390 | + if (null !== $flags) { |
|
391 | + return parent::split($delimiter, $limit, $flags); |
|
392 | + } |
|
393 | + |
|
394 | + $str = clone $this; |
|
395 | + $chunks = $this->ignoreCase |
|
396 | + ? preg_split('{'.preg_quote($delimiter).'}iD', $this->string, $limit) |
|
397 | + : explode($delimiter, $this->string, $limit); |
|
398 | + |
|
399 | + foreach ($chunks as &$chunk) { |
|
400 | + $str->string = $chunk; |
|
401 | + $chunk = clone $str; |
|
402 | + } |
|
403 | + |
|
404 | + return $chunks; |
|
405 | + } |
|
406 | + |
|
407 | + public function startsWith($prefix): bool |
|
408 | + { |
|
409 | + if ($prefix instanceof parent) { |
|
410 | + $prefix = $prefix->string; |
|
411 | + } elseif (!\is_string($prefix)) { |
|
412 | + return parent::startsWith($prefix); |
|
413 | + } |
|
414 | + |
|
415 | + return '' !== $prefix && 0 === ($this->ignoreCase ? strncasecmp($this->string, $prefix, \strlen($prefix)) : strncmp($this->string, $prefix, \strlen($prefix))); |
|
416 | + } |
|
417 | + |
|
418 | + public function title(bool $allWords = false): parent |
|
419 | + { |
|
420 | + $str = clone $this; |
|
421 | + $str->string = $allWords ? ucwords($str->string) : ucfirst($str->string); |
|
422 | + |
|
423 | + return $str; |
|
424 | + } |
|
425 | + |
|
426 | + public function toUnicodeString(string $fromEncoding = null): UnicodeString |
|
427 | + { |
|
428 | + return new UnicodeString($this->toCodePointString($fromEncoding)->string); |
|
429 | + } |
|
430 | + |
|
431 | + public function toCodePointString(string $fromEncoding = null): CodePointString |
|
432 | + { |
|
433 | + $u = new CodePointString(); |
|
434 | + |
|
435 | + if (\in_array($fromEncoding, [null, 'utf8', 'utf-8', 'UTF8', 'UTF-8'], true) && preg_match('//u', $this->string)) { |
|
436 | + $u->string = $this->string; |
|
437 | + |
|
438 | + return $u; |
|
439 | + } |
|
440 | 440 | |
441 | - set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
442 | - |
|
443 | - try { |
|
444 | - try { |
|
445 | - $validEncoding = false !== mb_detect_encoding($this->string, $fromEncoding ?? 'Windows-1252', true); |
|
446 | - } catch (InvalidArgumentException $e) { |
|
447 | - if (!\function_exists('iconv')) { |
|
448 | - throw $e; |
|
449 | - } |
|
450 | - |
|
451 | - $u->string = iconv($fromEncoding ?? 'Windows-1252', 'UTF-8', $this->string); |
|
441 | + set_error_handler(static function ($t, $m) { throw new InvalidArgumentException($m); }); |
|
442 | + |
|
443 | + try { |
|
444 | + try { |
|
445 | + $validEncoding = false !== mb_detect_encoding($this->string, $fromEncoding ?? 'Windows-1252', true); |
|
446 | + } catch (InvalidArgumentException $e) { |
|
447 | + if (!\function_exists('iconv')) { |
|
448 | + throw $e; |
|
449 | + } |
|
450 | + |
|
451 | + $u->string = iconv($fromEncoding ?? 'Windows-1252', 'UTF-8', $this->string); |
|
452 | 452 | |
453 | - return $u; |
|
454 | - } |
|
455 | - } finally { |
|
456 | - restore_error_handler(); |
|
457 | - } |
|
458 | - |
|
459 | - if (!$validEncoding) { |
|
460 | - throw new InvalidArgumentException(sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252')); |
|
461 | - } |
|
462 | - |
|
463 | - $u->string = mb_convert_encoding($this->string, 'UTF-8', $fromEncoding ?? 'Windows-1252'); |
|
464 | - |
|
465 | - return $u; |
|
466 | - } |
|
453 | + return $u; |
|
454 | + } |
|
455 | + } finally { |
|
456 | + restore_error_handler(); |
|
457 | + } |
|
458 | + |
|
459 | + if (!$validEncoding) { |
|
460 | + throw new InvalidArgumentException(sprintf('Invalid "%s" string.', $fromEncoding ?? 'Windows-1252')); |
|
461 | + } |
|
462 | + |
|
463 | + $u->string = mb_convert_encoding($this->string, 'UTF-8', $fromEncoding ?? 'Windows-1252'); |
|
464 | + |
|
465 | + return $u; |
|
466 | + } |
|
467 | 467 | |
468 | - public function trim(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
469 | - { |
|
470 | - $str = clone $this; |
|
471 | - $str->string = trim($str->string, $chars); |
|
472 | - |
|
473 | - return $str; |
|
474 | - } |
|
468 | + public function trim(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
469 | + { |
|
470 | + $str = clone $this; |
|
471 | + $str->string = trim($str->string, $chars); |
|
472 | + |
|
473 | + return $str; |
|
474 | + } |
|
475 | 475 | |
476 | - public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
477 | - { |
|
478 | - $str = clone $this; |
|
479 | - $str->string = rtrim($str->string, $chars); |
|
480 | - |
|
481 | - return $str; |
|
482 | - } |
|
476 | + public function trimEnd(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
477 | + { |
|
478 | + $str = clone $this; |
|
479 | + $str->string = rtrim($str->string, $chars); |
|
480 | + |
|
481 | + return $str; |
|
482 | + } |
|
483 | 483 | |
484 | - public function trimStart(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
485 | - { |
|
486 | - $str = clone $this; |
|
487 | - $str->string = ltrim($str->string, $chars); |
|
484 | + public function trimStart(string $chars = " \t\n\r\0\x0B\x0C"): parent |
|
485 | + { |
|
486 | + $str = clone $this; |
|
487 | + $str->string = ltrim($str->string, $chars); |
|
488 | 488 | |
489 | - return $str; |
|
490 | - } |
|
489 | + return $str; |
|
490 | + } |
|
491 | 491 | |
492 | - public function upper(): parent |
|
493 | - { |
|
494 | - $str = clone $this; |
|
495 | - $str->string = strtoupper($str->string); |
|
492 | + public function upper(): parent |
|
493 | + { |
|
494 | + $str = clone $this; |
|
495 | + $str->string = strtoupper($str->string); |
|
496 | 496 | |
497 | - return $str; |
|
498 | - } |
|
497 | + return $str; |
|
498 | + } |
|
499 | 499 | |
500 | - public function width(bool $ignoreAnsiDecoration = true): int |
|
501 | - { |
|
502 | - $string = preg_match('//u', $this->string) ? $this->string : preg_replace('/[\x80-\xFF]/', '?', $this->string); |
|
500 | + public function width(bool $ignoreAnsiDecoration = true): int |
|
501 | + { |
|
502 | + $string = preg_match('//u', $this->string) ? $this->string : preg_replace('/[\x80-\xFF]/', '?', $this->string); |
|
503 | 503 | |
504 | - return (new CodePointString($string))->width($ignoreAnsiDecoration); |
|
505 | - } |
|
504 | + return (new CodePointString($string))->width($ignoreAnsiDecoration); |
|
505 | + } |
|
506 | 506 | } |
@@ -18,140 +18,140 @@ |
||
18 | 18 | */ |
19 | 19 | final class FrenchInflector implements InflectorInterface |
20 | 20 | { |
21 | - /** |
|
22 | - * A list of all rules for pluralise. |
|
23 | - * |
|
24 | - * @see https://la-conjugaison.nouvelobs.com/regles/grammaire/le-pluriel-des-noms-121.php |
|
25 | - */ |
|
26 | - private const PLURALIZE_REGEXP = [ |
|
27 | - // First entry: regexp |
|
28 | - // Second entry: replacement |
|
29 | - |
|
30 | - // Words finishing with "s", "x" or "z" are invariables |
|
31 | - // Les mots finissant par "s", "x" ou "z" sont invariables |
|
32 | - ['/(s|x|z)$/i', '\1'], |
|
33 | - |
|
34 | - // Words finishing with "eau" are pluralized with a "x" |
|
35 | - // Les mots finissant par "eau" prennent tous un "x" au pluriel |
|
36 | - ['/(eau)$/i', '\1x'], |
|
37 | - |
|
38 | - // Words finishing with "au" are pluralized with a "x" excepted "landau" |
|
39 | - // Les mots finissant par "au" prennent un "x" au pluriel sauf "landau" |
|
40 | - ['/^(landau)$/i', '\1s'], |
|
41 | - ['/(au)$/i', '\1x'], |
|
42 | - |
|
43 | - // Words finishing with "eu" are pluralized with a "x" excepted "pneu", "bleu", "émeu" |
|
44 | - // Les mots finissant en "eu" prennent un "x" au pluriel sauf "pneu", "bleu", "émeu" |
|
45 | - ['/^(pneu|bleu|émeu)$/i', '\1s'], |
|
46 | - ['/(eu)$/i', '\1x'], |
|
47 | - |
|
48 | - // Words finishing with "al" are pluralized with a "aux" excepted |
|
49 | - // Les mots finissant en "al" se terminent en "aux" sauf |
|
50 | - ['/^(bal|carnaval|caracal|chacal|choral|corral|étal|festival|récital|val)$/i', '\1s'], |
|
51 | - ['/al$/i', '\1aux'], |
|
52 | - |
|
53 | - // Aspirail, bail, corail, émail, fermail, soupirail, travail, vantail et vitrail font leur pluriel en -aux |
|
54 | - ['/^(aspir|b|cor|ém|ferm|soupir|trav|vant|vitr)ail$/i', '\1aux'], |
|
55 | - |
|
56 | - // Bijou, caillou, chou, genou, hibou, joujou et pou qui prennent un x au pluriel |
|
57 | - ['/^(bij|caill|ch|gen|hib|jouj|p)ou$/i', '\1oux'], |
|
58 | - |
|
59 | - // Invariable words |
|
60 | - ['/^(cinquante|soixante|mille)$/i', '\1'], |
|
61 | - |
|
62 | - // French titles |
|
63 | - ['/^(mon|ma)(sieur|dame|demoiselle|seigneur)$/', 'mes\2s'], |
|
64 | - ['/^(Mon|Ma)(sieur|dame|demoiselle|seigneur)$/', 'Mes\2s'], |
|
65 | - ]; |
|
66 | - |
|
67 | - /** |
|
68 | - * A list of all rules for singularize. |
|
69 | - */ |
|
70 | - private const SINGULARIZE_REGEXP = [ |
|
71 | - // First entry: regexp |
|
72 | - // Second entry: replacement |
|
73 | - |
|
74 | - // Aspirail, bail, corail, émail, fermail, soupirail, travail, vantail et vitrail font leur pluriel en -aux |
|
75 | - ['/((aspir|b|cor|ém|ferm|soupir|trav|vant|vitr))aux$/i', '\1ail'], |
|
76 | - |
|
77 | - // Words finishing with "eau" are pluralized with a "x" |
|
78 | - // Les mots finissant par "eau" prennent tous un "x" au pluriel |
|
79 | - ['/(eau)x$/i', '\1'], |
|
80 | - |
|
81 | - // Words finishing with "al" are pluralized with a "aux" expected |
|
82 | - // Les mots finissant en "al" se terminent en "aux" sauf |
|
83 | - ['/(amir|anim|arsen|boc|can|capit|capor|chev|crist|génér|hopit|hôpit|idé|journ|littor|loc|m|mét|minér|princip|radic|termin)aux$/i', '\1al'], |
|
84 | - |
|
85 | - // Words finishing with "au" are pluralized with a "x" excepted "landau" |
|
86 | - // Les mots finissant par "au" prennent un "x" au pluriel sauf "landau" |
|
87 | - ['/(au)x$/i', '\1'], |
|
88 | - |
|
89 | - // Words finishing with "eu" are pluralized with a "x" excepted "pneu", "bleu", "émeu" |
|
90 | - // Les mots finissant en "eu" prennent un "x" au pluriel sauf "pneu", "bleu", "émeu" |
|
91 | - ['/(eu)x$/i', '\1'], |
|
92 | - |
|
93 | - // Words finishing with "ou" are pluralized with a "s" excepted bijou, caillou, chou, genou, hibou, joujou, pou |
|
94 | - // Les mots finissant par "ou" prennent un "s" sauf bijou, caillou, chou, genou, hibou, joujou, pou |
|
95 | - ['/(bij|caill|ch|gen|hib|jouj|p)oux$/i', '\1ou'], |
|
96 | - |
|
97 | - // French titles |
|
98 | - ['/^mes(dame|demoiselle)s$/', 'ma\1'], |
|
99 | - ['/^Mes(dame|demoiselle)s$/', 'Ma\1'], |
|
100 | - ['/^mes(sieur|seigneur)s$/', 'mon\1'], |
|
101 | - ['/^Mes(sieur|seigneur)s$/', 'Mon\1'], |
|
102 | - |
|
103 | - //Default rule |
|
104 | - ['/s$/i', ''], |
|
105 | - ]; |
|
106 | - |
|
107 | - /** |
|
108 | - * A list of words which should not be inflected. |
|
109 | - * This list is only used by singularize. |
|
110 | - */ |
|
111 | - private const UNINFLECTED = '/^(abcès|accès|abus|albatros|anchois|anglais|autobus|bois|brebis|carquois|cas|chas|colis|concours|corps|cours|cyprès|décès|devis|discours|dos|embarras|engrais|entrelacs|excès|fils|fois|gâchis|gars|glas|héros|intrus|jars|jus|kermès|lacis|legs|lilas|marais|mars|matelas|mépris|mets|mois|mors|obus|os|palais|paradis|parcours|pardessus|pays|plusieurs|poids|pois|pouls|printemps|processus|progrès|puits|pus|rabais|radis|recors|recours|refus|relais|remords|remous|rictus|rhinocéros|repas|rubis|sas|secours|sens|souris|succès|talus|tapis|tas|taudis|temps|tiers|univers|velours|verglas|vernis|virus)$/i'; |
|
112 | - |
|
113 | - /** |
|
114 | - * {@inheritdoc} |
|
115 | - */ |
|
116 | - public function singularize(string $plural): array |
|
117 | - { |
|
118 | - if ($this->isInflectedWord($plural)) { |
|
119 | - return [$plural]; |
|
120 | - } |
|
121 | - |
|
122 | - foreach (self::SINGULARIZE_REGEXP as $rule) { |
|
123 | - [$regexp, $replace] = $rule; |
|
124 | - |
|
125 | - if (1 === preg_match($regexp, $plural)) { |
|
126 | - return [preg_replace($regexp, $replace, $plural)]; |
|
127 | - } |
|
128 | - } |
|
129 | - |
|
130 | - return [$plural]; |
|
131 | - } |
|
132 | - |
|
133 | - /** |
|
134 | - * {@inheritdoc} |
|
135 | - */ |
|
136 | - public function pluralize(string $singular): array |
|
137 | - { |
|
138 | - if ($this->isInflectedWord($singular)) { |
|
139 | - return [$singular]; |
|
140 | - } |
|
141 | - |
|
142 | - foreach (self::PLURALIZE_REGEXP as $rule) { |
|
143 | - [$regexp, $replace] = $rule; |
|
144 | - |
|
145 | - if (1 === preg_match($regexp, $singular)) { |
|
146 | - return [preg_replace($regexp, $replace, $singular)]; |
|
147 | - } |
|
148 | - } |
|
149 | - |
|
150 | - return [$singular.'s']; |
|
151 | - } |
|
152 | - |
|
153 | - private function isInflectedWord(string $word): bool |
|
154 | - { |
|
155 | - return 1 === preg_match(self::UNINFLECTED, $word); |
|
156 | - } |
|
21 | + /** |
|
22 | + * A list of all rules for pluralise. |
|
23 | + * |
|
24 | + * @see https://la-conjugaison.nouvelobs.com/regles/grammaire/le-pluriel-des-noms-121.php |
|
25 | + */ |
|
26 | + private const PLURALIZE_REGEXP = [ |
|
27 | + // First entry: regexp |
|
28 | + // Second entry: replacement |
|
29 | + |
|
30 | + // Words finishing with "s", "x" or "z" are invariables |
|
31 | + // Les mots finissant par "s", "x" ou "z" sont invariables |
|
32 | + ['/(s|x|z)$/i', '\1'], |
|
33 | + |
|
34 | + // Words finishing with "eau" are pluralized with a "x" |
|
35 | + // Les mots finissant par "eau" prennent tous un "x" au pluriel |
|
36 | + ['/(eau)$/i', '\1x'], |
|
37 | + |
|
38 | + // Words finishing with "au" are pluralized with a "x" excepted "landau" |
|
39 | + // Les mots finissant par "au" prennent un "x" au pluriel sauf "landau" |
|
40 | + ['/^(landau)$/i', '\1s'], |
|
41 | + ['/(au)$/i', '\1x'], |
|
42 | + |
|
43 | + // Words finishing with "eu" are pluralized with a "x" excepted "pneu", "bleu", "émeu" |
|
44 | + // Les mots finissant en "eu" prennent un "x" au pluriel sauf "pneu", "bleu", "émeu" |
|
45 | + ['/^(pneu|bleu|émeu)$/i', '\1s'], |
|
46 | + ['/(eu)$/i', '\1x'], |
|
47 | + |
|
48 | + // Words finishing with "al" are pluralized with a "aux" excepted |
|
49 | + // Les mots finissant en "al" se terminent en "aux" sauf |
|
50 | + ['/^(bal|carnaval|caracal|chacal|choral|corral|étal|festival|récital|val)$/i', '\1s'], |
|
51 | + ['/al$/i', '\1aux'], |
|
52 | + |
|
53 | + // Aspirail, bail, corail, émail, fermail, soupirail, travail, vantail et vitrail font leur pluriel en -aux |
|
54 | + ['/^(aspir|b|cor|ém|ferm|soupir|trav|vant|vitr)ail$/i', '\1aux'], |
|
55 | + |
|
56 | + // Bijou, caillou, chou, genou, hibou, joujou et pou qui prennent un x au pluriel |
|
57 | + ['/^(bij|caill|ch|gen|hib|jouj|p)ou$/i', '\1oux'], |
|
58 | + |
|
59 | + // Invariable words |
|
60 | + ['/^(cinquante|soixante|mille)$/i', '\1'], |
|
61 | + |
|
62 | + // French titles |
|
63 | + ['/^(mon|ma)(sieur|dame|demoiselle|seigneur)$/', 'mes\2s'], |
|
64 | + ['/^(Mon|Ma)(sieur|dame|demoiselle|seigneur)$/', 'Mes\2s'], |
|
65 | + ]; |
|
66 | + |
|
67 | + /** |
|
68 | + * A list of all rules for singularize. |
|
69 | + */ |
|
70 | + private const SINGULARIZE_REGEXP = [ |
|
71 | + // First entry: regexp |
|
72 | + // Second entry: replacement |
|
73 | + |
|
74 | + // Aspirail, bail, corail, émail, fermail, soupirail, travail, vantail et vitrail font leur pluriel en -aux |
|
75 | + ['/((aspir|b|cor|ém|ferm|soupir|trav|vant|vitr))aux$/i', '\1ail'], |
|
76 | + |
|
77 | + // Words finishing with "eau" are pluralized with a "x" |
|
78 | + // Les mots finissant par "eau" prennent tous un "x" au pluriel |
|
79 | + ['/(eau)x$/i', '\1'], |
|
80 | + |
|
81 | + // Words finishing with "al" are pluralized with a "aux" expected |
|
82 | + // Les mots finissant en "al" se terminent en "aux" sauf |
|
83 | + ['/(amir|anim|arsen|boc|can|capit|capor|chev|crist|génér|hopit|hôpit|idé|journ|littor|loc|m|mét|minér|princip|radic|termin)aux$/i', '\1al'], |
|
84 | + |
|
85 | + // Words finishing with "au" are pluralized with a "x" excepted "landau" |
|
86 | + // Les mots finissant par "au" prennent un "x" au pluriel sauf "landau" |
|
87 | + ['/(au)x$/i', '\1'], |
|
88 | + |
|
89 | + // Words finishing with "eu" are pluralized with a "x" excepted "pneu", "bleu", "émeu" |
|
90 | + // Les mots finissant en "eu" prennent un "x" au pluriel sauf "pneu", "bleu", "émeu" |
|
91 | + ['/(eu)x$/i', '\1'], |
|
92 | + |
|
93 | + // Words finishing with "ou" are pluralized with a "s" excepted bijou, caillou, chou, genou, hibou, joujou, pou |
|
94 | + // Les mots finissant par "ou" prennent un "s" sauf bijou, caillou, chou, genou, hibou, joujou, pou |
|
95 | + ['/(bij|caill|ch|gen|hib|jouj|p)oux$/i', '\1ou'], |
|
96 | + |
|
97 | + // French titles |
|
98 | + ['/^mes(dame|demoiselle)s$/', 'ma\1'], |
|
99 | + ['/^Mes(dame|demoiselle)s$/', 'Ma\1'], |
|
100 | + ['/^mes(sieur|seigneur)s$/', 'mon\1'], |
|
101 | + ['/^Mes(sieur|seigneur)s$/', 'Mon\1'], |
|
102 | + |
|
103 | + //Default rule |
|
104 | + ['/s$/i', ''], |
|
105 | + ]; |
|
106 | + |
|
107 | + /** |
|
108 | + * A list of words which should not be inflected. |
|
109 | + * This list is only used by singularize. |
|
110 | + */ |
|
111 | + private const UNINFLECTED = '/^(abcès|accès|abus|albatros|anchois|anglais|autobus|bois|brebis|carquois|cas|chas|colis|concours|corps|cours|cyprès|décès|devis|discours|dos|embarras|engrais|entrelacs|excès|fils|fois|gâchis|gars|glas|héros|intrus|jars|jus|kermès|lacis|legs|lilas|marais|mars|matelas|mépris|mets|mois|mors|obus|os|palais|paradis|parcours|pardessus|pays|plusieurs|poids|pois|pouls|printemps|processus|progrès|puits|pus|rabais|radis|recors|recours|refus|relais|remords|remous|rictus|rhinocéros|repas|rubis|sas|secours|sens|souris|succès|talus|tapis|tas|taudis|temps|tiers|univers|velours|verglas|vernis|virus)$/i'; |
|
112 | + |
|
113 | + /** |
|
114 | + * {@inheritdoc} |
|
115 | + */ |
|
116 | + public function singularize(string $plural): array |
|
117 | + { |
|
118 | + if ($this->isInflectedWord($plural)) { |
|
119 | + return [$plural]; |
|
120 | + } |
|
121 | + |
|
122 | + foreach (self::SINGULARIZE_REGEXP as $rule) { |
|
123 | + [$regexp, $replace] = $rule; |
|
124 | + |
|
125 | + if (1 === preg_match($regexp, $plural)) { |
|
126 | + return [preg_replace($regexp, $replace, $plural)]; |
|
127 | + } |
|
128 | + } |
|
129 | + |
|
130 | + return [$plural]; |
|
131 | + } |
|
132 | + |
|
133 | + /** |
|
134 | + * {@inheritdoc} |
|
135 | + */ |
|
136 | + public function pluralize(string $singular): array |
|
137 | + { |
|
138 | + if ($this->isInflectedWord($singular)) { |
|
139 | + return [$singular]; |
|
140 | + } |
|
141 | + |
|
142 | + foreach (self::PLURALIZE_REGEXP as $rule) { |
|
143 | + [$regexp, $replace] = $rule; |
|
144 | + |
|
145 | + if (1 === preg_match($regexp, $singular)) { |
|
146 | + return [preg_replace($regexp, $replace, $singular)]; |
|
147 | + } |
|
148 | + } |
|
149 | + |
|
150 | + return [$singular.'s']; |
|
151 | + } |
|
152 | + |
|
153 | + private function isInflectedWord(string $word): bool |
|
154 | + { |
|
155 | + return 1 === preg_match(self::UNINFLECTED, $word); |
|
156 | + } |
|
157 | 157 | } |
@@ -13,21 +13,21 @@ |
||
13 | 13 | |
14 | 14 | interface InflectorInterface |
15 | 15 | { |
16 | - /** |
|
17 | - * Returns the singular forms of a string. |
|
18 | - * |
|
19 | - * If the method can't determine the form with certainty, several possible singulars are returned. |
|
20 | - * |
|
21 | - * @return string[] An array of possible singular forms |
|
22 | - */ |
|
23 | - public function singularize(string $plural): array; |
|
16 | + /** |
|
17 | + * Returns the singular forms of a string. |
|
18 | + * |
|
19 | + * If the method can't determine the form with certainty, several possible singulars are returned. |
|
20 | + * |
|
21 | + * @return string[] An array of possible singular forms |
|
22 | + */ |
|
23 | + public function singularize(string $plural): array; |
|
24 | 24 | |
25 | - /** |
|
26 | - * Returns the plural forms of a string. |
|
27 | - * |
|
28 | - * If the method can't determine the form with certainty, several possible plurals are returned. |
|
29 | - * |
|
30 | - * @return string[] An array of possible plural forms |
|
31 | - */ |
|
32 | - public function pluralize(string $singular): array; |
|
25 | + /** |
|
26 | + * Returns the plural forms of a string. |
|
27 | + * |
|
28 | + * If the method can't determine the form with certainty, several possible plurals are returned. |
|
29 | + * |
|
30 | + * @return string[] An array of possible plural forms |
|
31 | + */ |
|
32 | + public function pluralize(string $singular): array; |
|
33 | 33 | } |
@@ -13,496 +13,496 @@ |
||
13 | 13 | |
14 | 14 | final class EnglishInflector implements InflectorInterface |
15 | 15 | { |
16 | - /** |
|
17 | - * Map English plural to singular suffixes. |
|
18 | - * |
|
19 | - * @see http://english-zone.com/spelling/plurals.html |
|
20 | - */ |
|
21 | - private const PLURAL_MAP = [ |
|
22 | - // First entry: plural suffix, reversed |
|
23 | - // Second entry: length of plural suffix |
|
24 | - // Third entry: Whether the suffix may succeed a vocal |
|
25 | - // Fourth entry: Whether the suffix may succeed a consonant |
|
26 | - // Fifth entry: singular suffix, normal |
|
16 | + /** |
|
17 | + * Map English plural to singular suffixes. |
|
18 | + * |
|
19 | + * @see http://english-zone.com/spelling/plurals.html |
|
20 | + */ |
|
21 | + private const PLURAL_MAP = [ |
|
22 | + // First entry: plural suffix, reversed |
|
23 | + // Second entry: length of plural suffix |
|
24 | + // Third entry: Whether the suffix may succeed a vocal |
|
25 | + // Fourth entry: Whether the suffix may succeed a consonant |
|
26 | + // Fifth entry: singular suffix, normal |
|
27 | 27 | |
28 | - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
29 | - ['a', 1, true, true, ['on', 'um']], |
|
28 | + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
29 | + ['a', 1, true, true, ['on', 'um']], |
|
30 | 30 | |
31 | - // nebulae (nebula) |
|
32 | - ['ea', 2, true, true, 'a'], |
|
31 | + // nebulae (nebula) |
|
32 | + ['ea', 2, true, true, 'a'], |
|
33 | 33 | |
34 | - // services (service) |
|
35 | - ['secivres', 8, true, true, 'service'], |
|
34 | + // services (service) |
|
35 | + ['secivres', 8, true, true, 'service'], |
|
36 | 36 | |
37 | - // mice (mouse), lice (louse) |
|
38 | - ['eci', 3, false, true, 'ouse'], |
|
37 | + // mice (mouse), lice (louse) |
|
38 | + ['eci', 3, false, true, 'ouse'], |
|
39 | 39 | |
40 | - // geese (goose) |
|
41 | - ['esee', 4, false, true, 'oose'], |
|
40 | + // geese (goose) |
|
41 | + ['esee', 4, false, true, 'oose'], |
|
42 | 42 | |
43 | - // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) |
|
44 | - ['i', 1, true, true, 'us'], |
|
43 | + // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) |
|
44 | + ['i', 1, true, true, 'us'], |
|
45 | 45 | |
46 | - // men (man), women (woman) |
|
47 | - ['nem', 3, true, true, 'man'], |
|
46 | + // men (man), women (woman) |
|
47 | + ['nem', 3, true, true, 'man'], |
|
48 | 48 | |
49 | - // children (child) |
|
50 | - ['nerdlihc', 8, true, true, 'child'], |
|
49 | + // children (child) |
|
50 | + ['nerdlihc', 8, true, true, 'child'], |
|
51 | 51 | |
52 | - // oxen (ox) |
|
53 | - ['nexo', 4, false, false, 'ox'], |
|
52 | + // oxen (ox) |
|
53 | + ['nexo', 4, false, false, 'ox'], |
|
54 | 54 | |
55 | - // indices (index), appendices (appendix), prices (price) |
|
56 | - ['seci', 4, false, true, ['ex', 'ix', 'ice']], |
|
55 | + // indices (index), appendices (appendix), prices (price) |
|
56 | + ['seci', 4, false, true, ['ex', 'ix', 'ice']], |
|
57 | 57 | |
58 | - // selfies (selfie) |
|
59 | - ['seifles', 7, true, true, 'selfie'], |
|
58 | + // selfies (selfie) |
|
59 | + ['seifles', 7, true, true, 'selfie'], |
|
60 | 60 | |
61 | - // movies (movie) |
|
62 | - ['seivom', 6, true, true, 'movie'], |
|
61 | + // movies (movie) |
|
62 | + ['seivom', 6, true, true, 'movie'], |
|
63 | 63 | |
64 | - // conspectuses (conspectus), prospectuses (prospectus) |
|
65 | - ['sesutcep', 8, true, true, 'pectus'], |
|
64 | + // conspectuses (conspectus), prospectuses (prospectus) |
|
65 | + ['sesutcep', 8, true, true, 'pectus'], |
|
66 | 66 | |
67 | - // feet (foot) |
|
68 | - ['teef', 4, true, true, 'foot'], |
|
67 | + // feet (foot) |
|
68 | + ['teef', 4, true, true, 'foot'], |
|
69 | 69 | |
70 | - // geese (goose) |
|
71 | - ['eseeg', 5, true, true, 'goose'], |
|
70 | + // geese (goose) |
|
71 | + ['eseeg', 5, true, true, 'goose'], |
|
72 | 72 | |
73 | - // teeth (tooth) |
|
74 | - ['hteet', 5, true, true, 'tooth'], |
|
73 | + // teeth (tooth) |
|
74 | + ['hteet', 5, true, true, 'tooth'], |
|
75 | 75 | |
76 | - // news (news) |
|
77 | - ['swen', 4, true, true, 'news'], |
|
76 | + // news (news) |
|
77 | + ['swen', 4, true, true, 'news'], |
|
78 | 78 | |
79 | - // series (series) |
|
80 | - ['seires', 6, true, true, 'series'], |
|
79 | + // series (series) |
|
80 | + ['seires', 6, true, true, 'series'], |
|
81 | 81 | |
82 | - // babies (baby) |
|
83 | - ['sei', 3, false, true, 'y'], |
|
82 | + // babies (baby) |
|
83 | + ['sei', 3, false, true, 'y'], |
|
84 | 84 | |
85 | - // accesses (access), addresses (address), kisses (kiss) |
|
86 | - ['sess', 4, true, false, 'ss'], |
|
85 | + // accesses (access), addresses (address), kisses (kiss) |
|
86 | + ['sess', 4, true, false, 'ss'], |
|
87 | 87 | |
88 | - // analyses (analysis), ellipses (ellipsis), fungi (fungus), |
|
89 | - // neuroses (neurosis), theses (thesis), emphases (emphasis), |
|
90 | - // oases (oasis), crises (crisis), houses (house), bases (base), |
|
91 | - // atlases (atlas) |
|
92 | - ['ses', 3, true, true, ['s', 'se', 'sis']], |
|
88 | + // analyses (analysis), ellipses (ellipsis), fungi (fungus), |
|
89 | + // neuroses (neurosis), theses (thesis), emphases (emphasis), |
|
90 | + // oases (oasis), crises (crisis), houses (house), bases (base), |
|
91 | + // atlases (atlas) |
|
92 | + ['ses', 3, true, true, ['s', 'se', 'sis']], |
|
93 | 93 | |
94 | - // objectives (objective), alternative (alternatives) |
|
95 | - ['sevit', 5, true, true, 'tive'], |
|
94 | + // objectives (objective), alternative (alternatives) |
|
95 | + ['sevit', 5, true, true, 'tive'], |
|
96 | 96 | |
97 | - // drives (drive) |
|
98 | - ['sevird', 6, false, true, 'drive'], |
|
97 | + // drives (drive) |
|
98 | + ['sevird', 6, false, true, 'drive'], |
|
99 | 99 | |
100 | - // lives (life), wives (wife) |
|
101 | - ['sevi', 4, false, true, 'ife'], |
|
100 | + // lives (life), wives (wife) |
|
101 | + ['sevi', 4, false, true, 'ife'], |
|
102 | 102 | |
103 | - // moves (move) |
|
104 | - ['sevom', 5, true, true, 'move'], |
|
103 | + // moves (move) |
|
104 | + ['sevom', 5, true, true, 'move'], |
|
105 | 105 | |
106 | - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf), caves (cave), staves (staff) |
|
107 | - ['sev', 3, true, true, ['f', 've', 'ff']], |
|
106 | + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf), caves (cave), staves (staff) |
|
107 | + ['sev', 3, true, true, ['f', 've', 'ff']], |
|
108 | 108 | |
109 | - // axes (axis), axes (ax), axes (axe) |
|
110 | - ['sexa', 4, false, false, ['ax', 'axe', 'axis']], |
|
109 | + // axes (axis), axes (ax), axes (axe) |
|
110 | + ['sexa', 4, false, false, ['ax', 'axe', 'axis']], |
|
111 | 111 | |
112 | - // indexes (index), matrixes (matrix) |
|
113 | - ['sex', 3, true, false, 'x'], |
|
112 | + // indexes (index), matrixes (matrix) |
|
113 | + ['sex', 3, true, false, 'x'], |
|
114 | 114 | |
115 | - // quizzes (quiz) |
|
116 | - ['sezz', 4, true, false, 'z'], |
|
115 | + // quizzes (quiz) |
|
116 | + ['sezz', 4, true, false, 'z'], |
|
117 | 117 | |
118 | - // bureaus (bureau) |
|
119 | - ['suae', 4, false, true, 'eau'], |
|
118 | + // bureaus (bureau) |
|
119 | + ['suae', 4, false, true, 'eau'], |
|
120 | 120 | |
121 | - // fees (fee), trees (tree), employees (employee) |
|
122 | - ['see', 3, true, true, 'ee'], |
|
121 | + // fees (fee), trees (tree), employees (employee) |
|
122 | + ['see', 3, true, true, 'ee'], |
|
123 | 123 | |
124 | - // edges (edge) |
|
125 | - ['segd', 4, true, true, 'dge'], |
|
124 | + // edges (edge) |
|
125 | + ['segd', 4, true, true, 'dge'], |
|
126 | 126 | |
127 | - // roses (rose), garages (garage), cassettes (cassette), |
|
128 | - // waltzes (waltz), heroes (hero), bushes (bush), arches (arch), |
|
129 | - // shoes (shoe) |
|
130 | - ['se', 2, true, true, ['', 'e']], |
|
127 | + // roses (rose), garages (garage), cassettes (cassette), |
|
128 | + // waltzes (waltz), heroes (hero), bushes (bush), arches (arch), |
|
129 | + // shoes (shoe) |
|
130 | + ['se', 2, true, true, ['', 'e']], |
|
131 | 131 | |
132 | - // tags (tag) |
|
133 | - ['s', 1, true, true, ''], |
|
132 | + // tags (tag) |
|
133 | + ['s', 1, true, true, ''], |
|
134 | 134 | |
135 | - // chateaux (chateau) |
|
136 | - ['xuae', 4, false, true, 'eau'], |
|
135 | + // chateaux (chateau) |
|
136 | + ['xuae', 4, false, true, 'eau'], |
|
137 | 137 | |
138 | - // people (person) |
|
139 | - ['elpoep', 6, true, true, 'person'], |
|
140 | - ]; |
|
138 | + // people (person) |
|
139 | + ['elpoep', 6, true, true, 'person'], |
|
140 | + ]; |
|
141 | 141 | |
142 | - /** |
|
143 | - * Map English singular to plural suffixes. |
|
144 | - * |
|
145 | - * @see http://english-zone.com/spelling/plurals.html |
|
146 | - */ |
|
147 | - private const SINGULAR_MAP = [ |
|
148 | - // First entry: singular suffix, reversed |
|
149 | - // Second entry: length of singular suffix |
|
150 | - // Third entry: Whether the suffix may succeed a vocal |
|
151 | - // Fourth entry: Whether the suffix may succeed a consonant |
|
152 | - // Fifth entry: plural suffix, normal |
|
142 | + /** |
|
143 | + * Map English singular to plural suffixes. |
|
144 | + * |
|
145 | + * @see http://english-zone.com/spelling/plurals.html |
|
146 | + */ |
|
147 | + private const SINGULAR_MAP = [ |
|
148 | + // First entry: singular suffix, reversed |
|
149 | + // Second entry: length of singular suffix |
|
150 | + // Third entry: Whether the suffix may succeed a vocal |
|
151 | + // Fourth entry: Whether the suffix may succeed a consonant |
|
152 | + // Fifth entry: plural suffix, normal |
|
153 | 153 | |
154 | - // criterion (criteria) |
|
155 | - ['airetirc', 8, false, false, 'criterion'], |
|
154 | + // criterion (criteria) |
|
155 | + ['airetirc', 8, false, false, 'criterion'], |
|
156 | 156 | |
157 | - // nebulae (nebula) |
|
158 | - ['aluben', 6, false, false, 'nebulae'], |
|
157 | + // nebulae (nebula) |
|
158 | + ['aluben', 6, false, false, 'nebulae'], |
|
159 | 159 | |
160 | - // children (child) |
|
161 | - ['dlihc', 5, true, true, 'children'], |
|
160 | + // children (child) |
|
161 | + ['dlihc', 5, true, true, 'children'], |
|
162 | 162 | |
163 | - // prices (price) |
|
164 | - ['eci', 3, false, true, 'ices'], |
|
163 | + // prices (price) |
|
164 | + ['eci', 3, false, true, 'ices'], |
|
165 | 165 | |
166 | - // services (service) |
|
167 | - ['ecivres', 7, true, true, 'services'], |
|
166 | + // services (service) |
|
167 | + ['ecivres', 7, true, true, 'services'], |
|
168 | 168 | |
169 | - // lives (life), wives (wife) |
|
170 | - ['efi', 3, false, true, 'ives'], |
|
169 | + // lives (life), wives (wife) |
|
170 | + ['efi', 3, false, true, 'ives'], |
|
171 | 171 | |
172 | - // selfies (selfie) |
|
173 | - ['eifles', 6, true, true, 'selfies'], |
|
172 | + // selfies (selfie) |
|
173 | + ['eifles', 6, true, true, 'selfies'], |
|
174 | 174 | |
175 | - // movies (movie) |
|
176 | - ['eivom', 5, true, true, 'movies'], |
|
175 | + // movies (movie) |
|
176 | + ['eivom', 5, true, true, 'movies'], |
|
177 | 177 | |
178 | - // lice (louse) |
|
179 | - ['esuol', 5, false, true, 'lice'], |
|
178 | + // lice (louse) |
|
179 | + ['esuol', 5, false, true, 'lice'], |
|
180 | 180 | |
181 | - // mice (mouse) |
|
182 | - ['esuom', 5, false, true, 'mice'], |
|
181 | + // mice (mouse) |
|
182 | + ['esuom', 5, false, true, 'mice'], |
|
183 | 183 | |
184 | - // geese (goose) |
|
185 | - ['esoo', 4, false, true, 'eese'], |
|
184 | + // geese (goose) |
|
185 | + ['esoo', 4, false, true, 'eese'], |
|
186 | 186 | |
187 | - // houses (house), bases (base) |
|
188 | - ['es', 2, true, true, 'ses'], |
|
187 | + // houses (house), bases (base) |
|
188 | + ['es', 2, true, true, 'ses'], |
|
189 | 189 | |
190 | - // geese (goose) |
|
191 | - ['esoog', 5, true, true, 'geese'], |
|
190 | + // geese (goose) |
|
191 | + ['esoog', 5, true, true, 'geese'], |
|
192 | 192 | |
193 | - // caves (cave) |
|
194 | - ['ev', 2, true, true, 'ves'], |
|
193 | + // caves (cave) |
|
194 | + ['ev', 2, true, true, 'ves'], |
|
195 | 195 | |
196 | - // drives (drive) |
|
197 | - ['evird', 5, false, true, 'drives'], |
|
196 | + // drives (drive) |
|
197 | + ['evird', 5, false, true, 'drives'], |
|
198 | 198 | |
199 | - // objectives (objective), alternative (alternatives) |
|
200 | - ['evit', 4, true, true, 'tives'], |
|
199 | + // objectives (objective), alternative (alternatives) |
|
200 | + ['evit', 4, true, true, 'tives'], |
|
201 | 201 | |
202 | - // moves (move) |
|
203 | - ['evom', 4, true, true, 'moves'], |
|
202 | + // moves (move) |
|
203 | + ['evom', 4, true, true, 'moves'], |
|
204 | 204 | |
205 | - // staves (staff) |
|
206 | - ['ffats', 5, true, true, 'staves'], |
|
205 | + // staves (staff) |
|
206 | + ['ffats', 5, true, true, 'staves'], |
|
207 | 207 | |
208 | - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) |
|
209 | - ['ff', 2, true, true, 'ffs'], |
|
208 | + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) |
|
209 | + ['ff', 2, true, true, 'ffs'], |
|
210 | 210 | |
211 | - // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) |
|
212 | - ['f', 1, true, true, ['fs', 'ves']], |
|
211 | + // hooves (hoof), dwarves (dwarf), elves (elf), leaves (leaf) |
|
212 | + ['f', 1, true, true, ['fs', 'ves']], |
|
213 | 213 | |
214 | - // arches (arch) |
|
215 | - ['hc', 2, true, true, 'ches'], |
|
214 | + // arches (arch) |
|
215 | + ['hc', 2, true, true, 'ches'], |
|
216 | 216 | |
217 | - // bushes (bush) |
|
218 | - ['hs', 2, true, true, 'shes'], |
|
217 | + // bushes (bush) |
|
218 | + ['hs', 2, true, true, 'shes'], |
|
219 | 219 | |
220 | - // teeth (tooth) |
|
221 | - ['htoot', 5, true, true, 'teeth'], |
|
220 | + // teeth (tooth) |
|
221 | + ['htoot', 5, true, true, 'teeth'], |
|
222 | 222 | |
223 | - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
224 | - ['mu', 2, true, true, 'a'], |
|
223 | + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
224 | + ['mu', 2, true, true, 'a'], |
|
225 | 225 | |
226 | - // men (man), women (woman) |
|
227 | - ['nam', 3, true, true, 'men'], |
|
226 | + // men (man), women (woman) |
|
227 | + ['nam', 3, true, true, 'men'], |
|
228 | 228 | |
229 | - // people (person) |
|
230 | - ['nosrep', 6, true, true, ['persons', 'people']], |
|
229 | + // people (person) |
|
230 | + ['nosrep', 6, true, true, ['persons', 'people']], |
|
231 | 231 | |
232 | - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
233 | - ['noi', 3, true, true, 'ions'], |
|
232 | + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
233 | + ['noi', 3, true, true, 'ions'], |
|
234 | 234 | |
235 | - // coupon (coupons) |
|
236 | - ['nop', 3, true, true, 'pons'], |
|
235 | + // coupon (coupons) |
|
236 | + ['nop', 3, true, true, 'pons'], |
|
237 | 237 | |
238 | - // seasons (season), treasons (treason), poisons (poison), lessons (lesson) |
|
239 | - ['nos', 3, true, true, 'sons'], |
|
238 | + // seasons (season), treasons (treason), poisons (poison), lessons (lesson) |
|
239 | + ['nos', 3, true, true, 'sons'], |
|
240 | 240 | |
241 | - // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
242 | - ['no', 2, true, true, 'a'], |
|
241 | + // bacteria (bacterium), criteria (criterion), phenomena (phenomenon) |
|
242 | + ['no', 2, true, true, 'a'], |
|
243 | 243 | |
244 | - // echoes (echo) |
|
245 | - ['ohce', 4, true, true, 'echoes'], |
|
244 | + // echoes (echo) |
|
245 | + ['ohce', 4, true, true, 'echoes'], |
|
246 | 246 | |
247 | - // heroes (hero) |
|
248 | - ['oreh', 4, true, true, 'heroes'], |
|
247 | + // heroes (hero) |
|
248 | + ['oreh', 4, true, true, 'heroes'], |
|
249 | 249 | |
250 | - // atlases (atlas) |
|
251 | - ['salta', 5, true, true, 'atlases'], |
|
250 | + // atlases (atlas) |
|
251 | + ['salta', 5, true, true, 'atlases'], |
|
252 | 252 | |
253 | - // irises (iris) |
|
254 | - ['siri', 4, true, true, 'irises'], |
|
253 | + // irises (iris) |
|
254 | + ['siri', 4, true, true, 'irises'], |
|
255 | 255 | |
256 | - // analyses (analysis), ellipses (ellipsis), neuroses (neurosis) |
|
257 | - // theses (thesis), emphases (emphasis), oases (oasis), |
|
258 | - // crises (crisis) |
|
259 | - ['sis', 3, true, true, 'ses'], |
|
256 | + // analyses (analysis), ellipses (ellipsis), neuroses (neurosis) |
|
257 | + // theses (thesis), emphases (emphasis), oases (oasis), |
|
258 | + // crises (crisis) |
|
259 | + ['sis', 3, true, true, 'ses'], |
|
260 | 260 | |
261 | - // accesses (access), addresses (address), kisses (kiss) |
|
262 | - ['ss', 2, true, false, 'sses'], |
|
261 | + // accesses (access), addresses (address), kisses (kiss) |
|
262 | + ['ss', 2, true, false, 'sses'], |
|
263 | 263 | |
264 | - // syllabi (syllabus) |
|
265 | - ['suballys', 8, true, true, 'syllabi'], |
|
264 | + // syllabi (syllabus) |
|
265 | + ['suballys', 8, true, true, 'syllabi'], |
|
266 | 266 | |
267 | - // buses (bus) |
|
268 | - ['sub', 3, true, true, 'buses'], |
|
267 | + // buses (bus) |
|
268 | + ['sub', 3, true, true, 'buses'], |
|
269 | 269 | |
270 | - // circuses (circus) |
|
271 | - ['suc', 3, true, true, 'cuses'], |
|
270 | + // circuses (circus) |
|
271 | + ['suc', 3, true, true, 'cuses'], |
|
272 | 272 | |
273 | - // conspectuses (conspectus), prospectuses (prospectus) |
|
274 | - ['sutcep', 6, true, true, 'pectuses'], |
|
273 | + // conspectuses (conspectus), prospectuses (prospectus) |
|
274 | + ['sutcep', 6, true, true, 'pectuses'], |
|
275 | 275 | |
276 | - // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) |
|
277 | - ['su', 2, true, true, 'i'], |
|
276 | + // fungi (fungus), alumni (alumnus), syllabi (syllabus), radii (radius) |
|
277 | + ['su', 2, true, true, 'i'], |
|
278 | 278 | |
279 | - // news (news) |
|
280 | - ['swen', 4, true, true, 'news'], |
|
279 | + // news (news) |
|
280 | + ['swen', 4, true, true, 'news'], |
|
281 | 281 | |
282 | - // feet (foot) |
|
283 | - ['toof', 4, true, true, 'feet'], |
|
282 | + // feet (foot) |
|
283 | + ['toof', 4, true, true, 'feet'], |
|
284 | 284 | |
285 | - // chateaux (chateau), bureaus (bureau) |
|
286 | - ['uae', 3, false, true, ['eaus', 'eaux']], |
|
285 | + // chateaux (chateau), bureaus (bureau) |
|
286 | + ['uae', 3, false, true, ['eaus', 'eaux']], |
|
287 | 287 | |
288 | - // oxen (ox) |
|
289 | - ['xo', 2, false, false, 'oxen'], |
|
288 | + // oxen (ox) |
|
289 | + ['xo', 2, false, false, 'oxen'], |
|
290 | 290 | |
291 | - // hoaxes (hoax) |
|
292 | - ['xaoh', 4, true, false, 'hoaxes'], |
|
291 | + // hoaxes (hoax) |
|
292 | + ['xaoh', 4, true, false, 'hoaxes'], |
|
293 | 293 | |
294 | - // indices (index) |
|
295 | - ['xedni', 5, false, true, ['indicies', 'indexes']], |
|
294 | + // indices (index) |
|
295 | + ['xedni', 5, false, true, ['indicies', 'indexes']], |
|
296 | 296 | |
297 | - // boxes (box) |
|
298 | - ['xo', 2, false, true, 'oxes'], |
|
297 | + // boxes (box) |
|
298 | + ['xo', 2, false, true, 'oxes'], |
|
299 | 299 | |
300 | - // indexes (index), matrixes (matrix) |
|
301 | - ['x', 1, true, false, ['cies', 'xes']], |
|
300 | + // indexes (index), matrixes (matrix) |
|
301 | + ['x', 1, true, false, ['cies', 'xes']], |
|
302 | 302 | |
303 | - // appendices (appendix) |
|
304 | - ['xi', 2, false, true, 'ices'], |
|
303 | + // appendices (appendix) |
|
304 | + ['xi', 2, false, true, 'ices'], |
|
305 | 305 | |
306 | - // babies (baby) |
|
307 | - ['y', 1, false, true, 'ies'], |
|
306 | + // babies (baby) |
|
307 | + ['y', 1, false, true, 'ies'], |
|
308 | 308 | |
309 | - // quizzes (quiz) |
|
310 | - ['ziuq', 4, true, false, 'quizzes'], |
|
309 | + // quizzes (quiz) |
|
310 | + ['ziuq', 4, true, false, 'quizzes'], |
|
311 | 311 | |
312 | - // waltzes (waltz) |
|
313 | - ['z', 1, true, true, 'zes'], |
|
314 | - ]; |
|
312 | + // waltzes (waltz) |
|
313 | + ['z', 1, true, true, 'zes'], |
|
314 | + ]; |
|
315 | 315 | |
316 | - /** |
|
317 | - * A list of words which should not be inflected, reversed. |
|
318 | - */ |
|
319 | - private const UNINFLECTED = [ |
|
320 | - '', |
|
316 | + /** |
|
317 | + * A list of words which should not be inflected, reversed. |
|
318 | + */ |
|
319 | + private const UNINFLECTED = [ |
|
320 | + '', |
|
321 | 321 | |
322 | - // data |
|
323 | - 'atad', |
|
322 | + // data |
|
323 | + 'atad', |
|
324 | 324 | |
325 | - // deer |
|
326 | - 'reed', |
|
325 | + // deer |
|
326 | + 'reed', |
|
327 | 327 | |
328 | - // feedback |
|
329 | - 'kcabdeef', |
|
328 | + // feedback |
|
329 | + 'kcabdeef', |
|
330 | 330 | |
331 | - // fish |
|
332 | - 'hsif', |
|
331 | + // fish |
|
332 | + 'hsif', |
|
333 | 333 | |
334 | - // info |
|
335 | - 'ofni', |
|
334 | + // info |
|
335 | + 'ofni', |
|
336 | 336 | |
337 | - // moose |
|
338 | - 'esoom', |
|
337 | + // moose |
|
338 | + 'esoom', |
|
339 | 339 | |
340 | - // series |
|
341 | - 'seires', |
|
340 | + // series |
|
341 | + 'seires', |
|
342 | 342 | |
343 | - // sheep |
|
344 | - 'peehs', |
|
343 | + // sheep |
|
344 | + 'peehs', |
|
345 | 345 | |
346 | - // species |
|
347 | - 'seiceps', |
|
348 | - ]; |
|
349 | - |
|
350 | - /** |
|
351 | - * {@inheritdoc} |
|
352 | - */ |
|
353 | - public function singularize(string $plural): array |
|
354 | - { |
|
355 | - $pluralRev = strrev($plural); |
|
356 | - $lowerPluralRev = strtolower($pluralRev); |
|
357 | - $pluralLength = \strlen($lowerPluralRev); |
|
358 | - |
|
359 | - // Check if the word is one which is not inflected, return early if so |
|
360 | - if (\in_array($lowerPluralRev, self::UNINFLECTED, true)) { |
|
361 | - return [$plural]; |
|
362 | - } |
|
363 | - |
|
364 | - // The outer loop iterates over the entries of the plural table |
|
365 | - // The inner loop $j iterates over the characters of the plural suffix |
|
366 | - // in the plural table to compare them with the characters of the actual |
|
367 | - // given plural suffix |
|
368 | - foreach (self::PLURAL_MAP as $map) { |
|
369 | - $suffix = $map[0]; |
|
370 | - $suffixLength = $map[1]; |
|
371 | - $j = 0; |
|
372 | - |
|
373 | - // Compare characters in the plural table and of the suffix of the |
|
374 | - // given plural one by one |
|
375 | - while ($suffix[$j] === $lowerPluralRev[$j]) { |
|
376 | - // Let $j point to the next character |
|
377 | - ++$j; |
|
378 | - |
|
379 | - // Successfully compared the last character |
|
380 | - // Add an entry with the singular suffix to the singular array |
|
381 | - if ($j === $suffixLength) { |
|
382 | - // Is there any character preceding the suffix in the plural string? |
|
383 | - if ($j < $pluralLength) { |
|
384 | - $nextIsVocal = false !== strpos('aeiou', $lowerPluralRev[$j]); |
|
385 | - |
|
386 | - if (!$map[2] && $nextIsVocal) { |
|
387 | - // suffix may not succeed a vocal but next char is one |
|
388 | - break; |
|
389 | - } |
|
390 | - |
|
391 | - if (!$map[3] && !$nextIsVocal) { |
|
392 | - // suffix may not succeed a consonant but next char is one |
|
393 | - break; |
|
394 | - } |
|
395 | - } |
|
396 | - |
|
397 | - $newBase = substr($plural, 0, $pluralLength - $suffixLength); |
|
398 | - $newSuffix = $map[4]; |
|
399 | - |
|
400 | - // Check whether the first character in the plural suffix |
|
401 | - // is uppercased. If yes, uppercase the first character in |
|
402 | - // the singular suffix too |
|
403 | - $firstUpper = ctype_upper($pluralRev[$j - 1]); |
|
404 | - |
|
405 | - if (\is_array($newSuffix)) { |
|
406 | - $singulars = []; |
|
407 | - |
|
408 | - foreach ($newSuffix as $newSuffixEntry) { |
|
409 | - $singulars[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); |
|
410 | - } |
|
411 | - |
|
412 | - return $singulars; |
|
413 | - } |
|
414 | - |
|
415 | - return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; |
|
416 | - } |
|
417 | - |
|
418 | - // Suffix is longer than word |
|
419 | - if ($j === $pluralLength) { |
|
420 | - break; |
|
421 | - } |
|
422 | - } |
|
423 | - } |
|
424 | - |
|
425 | - // Assume that plural and singular is identical |
|
426 | - return [$plural]; |
|
427 | - } |
|
428 | - |
|
429 | - /** |
|
430 | - * {@inheritdoc} |
|
431 | - */ |
|
432 | - public function pluralize(string $singular): array |
|
433 | - { |
|
434 | - $singularRev = strrev($singular); |
|
435 | - $lowerSingularRev = strtolower($singularRev); |
|
436 | - $singularLength = \strlen($lowerSingularRev); |
|
437 | - |
|
438 | - // Check if the word is one which is not inflected, return early if so |
|
439 | - if (\in_array($lowerSingularRev, self::UNINFLECTED, true)) { |
|
440 | - return [$singular]; |
|
441 | - } |
|
442 | - |
|
443 | - // The outer loop iterates over the entries of the singular table |
|
444 | - // The inner loop $j iterates over the characters of the singular suffix |
|
445 | - // in the singular table to compare them with the characters of the actual |
|
446 | - // given singular suffix |
|
447 | - foreach (self::SINGULAR_MAP as $map) { |
|
448 | - $suffix = $map[0]; |
|
449 | - $suffixLength = $map[1]; |
|
450 | - $j = 0; |
|
451 | - |
|
452 | - // Compare characters in the singular table and of the suffix of the |
|
453 | - // given plural one by one |
|
454 | - |
|
455 | - while ($suffix[$j] === $lowerSingularRev[$j]) { |
|
456 | - // Let $j point to the next character |
|
457 | - ++$j; |
|
458 | - |
|
459 | - // Successfully compared the last character |
|
460 | - // Add an entry with the plural suffix to the plural array |
|
461 | - if ($j === $suffixLength) { |
|
462 | - // Is there any character preceding the suffix in the plural string? |
|
463 | - if ($j < $singularLength) { |
|
464 | - $nextIsVocal = false !== strpos('aeiou', $lowerSingularRev[$j]); |
|
465 | - |
|
466 | - if (!$map[2] && $nextIsVocal) { |
|
467 | - // suffix may not succeed a vocal but next char is one |
|
468 | - break; |
|
469 | - } |
|
470 | - |
|
471 | - if (!$map[3] && !$nextIsVocal) { |
|
472 | - // suffix may not succeed a consonant but next char is one |
|
473 | - break; |
|
474 | - } |
|
475 | - } |
|
476 | - |
|
477 | - $newBase = substr($singular, 0, $singularLength - $suffixLength); |
|
478 | - $newSuffix = $map[4]; |
|
479 | - |
|
480 | - // Check whether the first character in the singular suffix |
|
481 | - // is uppercased. If yes, uppercase the first character in |
|
482 | - // the singular suffix too |
|
483 | - $firstUpper = ctype_upper($singularRev[$j - 1]); |
|
484 | - |
|
485 | - if (\is_array($newSuffix)) { |
|
486 | - $plurals = []; |
|
487 | - |
|
488 | - foreach ($newSuffix as $newSuffixEntry) { |
|
489 | - $plurals[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); |
|
490 | - } |
|
491 | - |
|
492 | - return $plurals; |
|
493 | - } |
|
494 | - |
|
495 | - return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; |
|
496 | - } |
|
497 | - |
|
498 | - // Suffix is longer than word |
|
499 | - if ($j === $singularLength) { |
|
500 | - break; |
|
501 | - } |
|
502 | - } |
|
503 | - } |
|
504 | - |
|
505 | - // Assume that plural is singular with a trailing `s` |
|
506 | - return [$singular.'s']; |
|
507 | - } |
|
346 | + // species |
|
347 | + 'seiceps', |
|
348 | + ]; |
|
349 | + |
|
350 | + /** |
|
351 | + * {@inheritdoc} |
|
352 | + */ |
|
353 | + public function singularize(string $plural): array |
|
354 | + { |
|
355 | + $pluralRev = strrev($plural); |
|
356 | + $lowerPluralRev = strtolower($pluralRev); |
|
357 | + $pluralLength = \strlen($lowerPluralRev); |
|
358 | + |
|
359 | + // Check if the word is one which is not inflected, return early if so |
|
360 | + if (\in_array($lowerPluralRev, self::UNINFLECTED, true)) { |
|
361 | + return [$plural]; |
|
362 | + } |
|
363 | + |
|
364 | + // The outer loop iterates over the entries of the plural table |
|
365 | + // The inner loop $j iterates over the characters of the plural suffix |
|
366 | + // in the plural table to compare them with the characters of the actual |
|
367 | + // given plural suffix |
|
368 | + foreach (self::PLURAL_MAP as $map) { |
|
369 | + $suffix = $map[0]; |
|
370 | + $suffixLength = $map[1]; |
|
371 | + $j = 0; |
|
372 | + |
|
373 | + // Compare characters in the plural table and of the suffix of the |
|
374 | + // given plural one by one |
|
375 | + while ($suffix[$j] === $lowerPluralRev[$j]) { |
|
376 | + // Let $j point to the next character |
|
377 | + ++$j; |
|
378 | + |
|
379 | + // Successfully compared the last character |
|
380 | + // Add an entry with the singular suffix to the singular array |
|
381 | + if ($j === $suffixLength) { |
|
382 | + // Is there any character preceding the suffix in the plural string? |
|
383 | + if ($j < $pluralLength) { |
|
384 | + $nextIsVocal = false !== strpos('aeiou', $lowerPluralRev[$j]); |
|
385 | + |
|
386 | + if (!$map[2] && $nextIsVocal) { |
|
387 | + // suffix may not succeed a vocal but next char is one |
|
388 | + break; |
|
389 | + } |
|
390 | + |
|
391 | + if (!$map[3] && !$nextIsVocal) { |
|
392 | + // suffix may not succeed a consonant but next char is one |
|
393 | + break; |
|
394 | + } |
|
395 | + } |
|
396 | + |
|
397 | + $newBase = substr($plural, 0, $pluralLength - $suffixLength); |
|
398 | + $newSuffix = $map[4]; |
|
399 | + |
|
400 | + // Check whether the first character in the plural suffix |
|
401 | + // is uppercased. If yes, uppercase the first character in |
|
402 | + // the singular suffix too |
|
403 | + $firstUpper = ctype_upper($pluralRev[$j - 1]); |
|
404 | + |
|
405 | + if (\is_array($newSuffix)) { |
|
406 | + $singulars = []; |
|
407 | + |
|
408 | + foreach ($newSuffix as $newSuffixEntry) { |
|
409 | + $singulars[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); |
|
410 | + } |
|
411 | + |
|
412 | + return $singulars; |
|
413 | + } |
|
414 | + |
|
415 | + return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; |
|
416 | + } |
|
417 | + |
|
418 | + // Suffix is longer than word |
|
419 | + if ($j === $pluralLength) { |
|
420 | + break; |
|
421 | + } |
|
422 | + } |
|
423 | + } |
|
424 | + |
|
425 | + // Assume that plural and singular is identical |
|
426 | + return [$plural]; |
|
427 | + } |
|
428 | + |
|
429 | + /** |
|
430 | + * {@inheritdoc} |
|
431 | + */ |
|
432 | + public function pluralize(string $singular): array |
|
433 | + { |
|
434 | + $singularRev = strrev($singular); |
|
435 | + $lowerSingularRev = strtolower($singularRev); |
|
436 | + $singularLength = \strlen($lowerSingularRev); |
|
437 | + |
|
438 | + // Check if the word is one which is not inflected, return early if so |
|
439 | + if (\in_array($lowerSingularRev, self::UNINFLECTED, true)) { |
|
440 | + return [$singular]; |
|
441 | + } |
|
442 | + |
|
443 | + // The outer loop iterates over the entries of the singular table |
|
444 | + // The inner loop $j iterates over the characters of the singular suffix |
|
445 | + // in the singular table to compare them with the characters of the actual |
|
446 | + // given singular suffix |
|
447 | + foreach (self::SINGULAR_MAP as $map) { |
|
448 | + $suffix = $map[0]; |
|
449 | + $suffixLength = $map[1]; |
|
450 | + $j = 0; |
|
451 | + |
|
452 | + // Compare characters in the singular table and of the suffix of the |
|
453 | + // given plural one by one |
|
454 | + |
|
455 | + while ($suffix[$j] === $lowerSingularRev[$j]) { |
|
456 | + // Let $j point to the next character |
|
457 | + ++$j; |
|
458 | + |
|
459 | + // Successfully compared the last character |
|
460 | + // Add an entry with the plural suffix to the plural array |
|
461 | + if ($j === $suffixLength) { |
|
462 | + // Is there any character preceding the suffix in the plural string? |
|
463 | + if ($j < $singularLength) { |
|
464 | + $nextIsVocal = false !== strpos('aeiou', $lowerSingularRev[$j]); |
|
465 | + |
|
466 | + if (!$map[2] && $nextIsVocal) { |
|
467 | + // suffix may not succeed a vocal but next char is one |
|
468 | + break; |
|
469 | + } |
|
470 | + |
|
471 | + if (!$map[3] && !$nextIsVocal) { |
|
472 | + // suffix may not succeed a consonant but next char is one |
|
473 | + break; |
|
474 | + } |
|
475 | + } |
|
476 | + |
|
477 | + $newBase = substr($singular, 0, $singularLength - $suffixLength); |
|
478 | + $newSuffix = $map[4]; |
|
479 | + |
|
480 | + // Check whether the first character in the singular suffix |
|
481 | + // is uppercased. If yes, uppercase the first character in |
|
482 | + // the singular suffix too |
|
483 | + $firstUpper = ctype_upper($singularRev[$j - 1]); |
|
484 | + |
|
485 | + if (\is_array($newSuffix)) { |
|
486 | + $plurals = []; |
|
487 | + |
|
488 | + foreach ($newSuffix as $newSuffixEntry) { |
|
489 | + $plurals[] = $newBase.($firstUpper ? ucfirst($newSuffixEntry) : $newSuffixEntry); |
|
490 | + } |
|
491 | + |
|
492 | + return $plurals; |
|
493 | + } |
|
494 | + |
|
495 | + return [$newBase.($firstUpper ? ucfirst($newSuffix) : $newSuffix)]; |
|
496 | + } |
|
497 | + |
|
498 | + // Suffix is longer than word |
|
499 | + if ($j === $singularLength) { |
|
500 | + break; |
|
501 | + } |
|
502 | + } |
|
503 | + } |
|
504 | + |
|
505 | + // Assume that plural is singular with a trailing `s` |
|
506 | + return [$singular.'s']; |
|
507 | + } |
|
508 | 508 | } |
@@ -19,16 +19,16 @@ |
||
19 | 19 | */ |
20 | 20 | class FileNotFoundException extends IOException |
21 | 21 | { |
22 | - public function __construct(string $message = null, int $code = 0, \Throwable $previous = null, string $path = null) |
|
23 | - { |
|
24 | - if (null === $message) { |
|
25 | - if (null === $path) { |
|
26 | - $message = 'File could not be found.'; |
|
27 | - } else { |
|
28 | - $message = sprintf('File "%s" could not be found.', $path); |
|
29 | - } |
|
30 | - } |
|
22 | + public function __construct(string $message = null, int $code = 0, \Throwable $previous = null, string $path = null) |
|
23 | + { |
|
24 | + if (null === $message) { |
|
25 | + if (null === $path) { |
|
26 | + $message = 'File could not be found.'; |
|
27 | + } else { |
|
28 | + $message = sprintf('File "%s" could not be found.', $path); |
|
29 | + } |
|
30 | + } |
|
31 | 31 | |
32 | - parent::__construct($message, $code, $previous, $path); |
|
33 | - } |
|
32 | + parent::__construct($message, $code, $previous, $path); |
|
33 | + } |
|
34 | 34 | } |
@@ -20,20 +20,20 @@ |
||
20 | 20 | */ |
21 | 21 | class IOException extends \RuntimeException implements IOExceptionInterface |
22 | 22 | { |
23 | - private $path; |
|
23 | + private $path; |
|
24 | 24 | |
25 | - public function __construct(string $message, int $code = 0, \Throwable $previous = null, string $path = null) |
|
26 | - { |
|
27 | - $this->path = $path; |
|
25 | + public function __construct(string $message, int $code = 0, \Throwable $previous = null, string $path = null) |
|
26 | + { |
|
27 | + $this->path = $path; |
|
28 | 28 | |
29 | - parent::__construct($message, $code, $previous); |
|
30 | - } |
|
29 | + parent::__construct($message, $code, $previous); |
|
30 | + } |
|
31 | 31 | |
32 | - /** |
|
33 | - * {@inheritdoc} |
|
34 | - */ |
|
35 | - public function getPath() |
|
36 | - { |
|
37 | - return $this->path; |
|
38 | - } |
|
32 | + /** |
|
33 | + * {@inheritdoc} |
|
34 | + */ |
|
35 | + public function getPath() |
|
36 | + { |
|
37 | + return $this->path; |
|
38 | + } |
|
39 | 39 | } |
@@ -18,10 +18,10 @@ |
||
18 | 18 | */ |
19 | 19 | interface IOExceptionInterface extends ExceptionInterface |
20 | 20 | { |
21 | - /** |
|
22 | - * Returns the associated path for the exception. |
|
23 | - * |
|
24 | - * @return string|null The path |
|
25 | - */ |
|
26 | - public function getPath(); |
|
21 | + /** |
|
22 | + * Returns the associated path for the exception. |
|
23 | + * |
|
24 | + * @return string|null The path |
|
25 | + */ |
|
26 | + public function getPath(); |
|
27 | 27 | } |
@@ -22,737 +22,737 @@ |
||
22 | 22 | */ |
23 | 23 | class Filesystem |
24 | 24 | { |
25 | - private static $lastError; |
|
26 | - |
|
27 | - /** |
|
28 | - * Copies a file. |
|
29 | - * |
|
30 | - * If the target file is older than the origin file, it's always overwritten. |
|
31 | - * If the target file is newer, it is overwritten only when the |
|
32 | - * $overwriteNewerFiles option is set to true. |
|
33 | - * |
|
34 | - * @throws FileNotFoundException When originFile doesn't exist |
|
35 | - * @throws IOException When copy fails |
|
36 | - */ |
|
37 | - public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false) |
|
38 | - { |
|
39 | - $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://'); |
|
40 | - if ($originIsLocal && !is_file($originFile)) { |
|
41 | - throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); |
|
42 | - } |
|
43 | - |
|
44 | - $this->mkdir(\dirname($targetFile)); |
|
45 | - |
|
46 | - $doCopy = true; |
|
47 | - if (!$overwriteNewerFiles && null === parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) { |
|
48 | - $doCopy = filemtime($originFile) > filemtime($targetFile); |
|
49 | - } |
|
50 | - |
|
51 | - if ($doCopy) { |
|
52 | - // https://bugs.php.net/64634 |
|
53 | - if (!$source = self::box('fopen', $originFile, 'r')) { |
|
54 | - throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|
55 | - } |
|
56 | - |
|
57 | - // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default |
|
58 | - if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) { |
|
59 | - throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|
60 | - } |
|
61 | - |
|
62 | - $bytesCopied = stream_copy_to_stream($source, $target); |
|
63 | - fclose($source); |
|
64 | - fclose($target); |
|
65 | - unset($source, $target); |
|
66 | - |
|
67 | - if (!is_file($targetFile)) { |
|
68 | - throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); |
|
69 | - } |
|
70 | - |
|
71 | - if ($originIsLocal) { |
|
72 | - // Like `cp`, preserve executable permission bits |
|
73 | - self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); |
|
74 | - |
|
75 | - if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { |
|
76 | - throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile); |
|
77 | - } |
|
78 | - } |
|
79 | - } |
|
80 | - } |
|
81 | - |
|
82 | - /** |
|
83 | - * Creates a directory recursively. |
|
84 | - * |
|
85 | - * @param string|iterable $dirs The directory path |
|
86 | - * |
|
87 | - * @throws IOException On any directory creation failure |
|
88 | - */ |
|
89 | - public function mkdir($dirs, int $mode = 0777) |
|
90 | - { |
|
91 | - foreach ($this->toIterable($dirs) as $dir) { |
|
92 | - if (is_dir($dir)) { |
|
93 | - continue; |
|
94 | - } |
|
95 | - |
|
96 | - if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { |
|
97 | - throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); |
|
98 | - } |
|
99 | - } |
|
100 | - } |
|
101 | - |
|
102 | - /** |
|
103 | - * Checks the existence of files or directories. |
|
104 | - * |
|
105 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to check |
|
106 | - * |
|
107 | - * @return bool true if the file exists, false otherwise |
|
108 | - */ |
|
109 | - public function exists($files) |
|
110 | - { |
|
111 | - $maxPathLength = \PHP_MAXPATHLEN - 2; |
|
112 | - |
|
113 | - foreach ($this->toIterable($files) as $file) { |
|
114 | - if (\strlen($file) > $maxPathLength) { |
|
115 | - throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); |
|
116 | - } |
|
117 | - |
|
118 | - if (!file_exists($file)) { |
|
119 | - return false; |
|
120 | - } |
|
121 | - } |
|
122 | - |
|
123 | - return true; |
|
124 | - } |
|
125 | - |
|
126 | - /** |
|
127 | - * Sets access and modification time of file. |
|
128 | - * |
|
129 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to create |
|
130 | - * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used |
|
131 | - * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used |
|
132 | - * |
|
133 | - * @throws IOException When touch fails |
|
134 | - */ |
|
135 | - public function touch($files, int $time = null, int $atime = null) |
|
136 | - { |
|
137 | - foreach ($this->toIterable($files) as $file) { |
|
138 | - if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { |
|
139 | - throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); |
|
140 | - } |
|
141 | - } |
|
142 | - } |
|
143 | - |
|
144 | - /** |
|
145 | - * Removes files or directories. |
|
146 | - * |
|
147 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove |
|
148 | - * |
|
149 | - * @throws IOException When removal fails |
|
150 | - */ |
|
151 | - public function remove($files) |
|
152 | - { |
|
153 | - if ($files instanceof \Traversable) { |
|
154 | - $files = iterator_to_array($files, false); |
|
155 | - } elseif (!\is_array($files)) { |
|
156 | - $files = [$files]; |
|
157 | - } |
|
158 | - |
|
159 | - self::doRemove($files, false); |
|
160 | - } |
|
161 | - |
|
162 | - private static function doRemove(array $files, bool $isRecursive): void |
|
163 | - { |
|
164 | - $files = array_reverse($files); |
|
165 | - foreach ($files as $file) { |
|
166 | - if (is_link($file)) { |
|
167 | - // See https://bugs.php.net/52176 |
|
168 | - if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { |
|
169 | - throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); |
|
170 | - } |
|
171 | - } elseif (is_dir($file)) { |
|
172 | - if (!$isRecursive) { |
|
173 | - $tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.')); |
|
174 | - |
|
175 | - if (file_exists($tmpName)) { |
|
176 | - try { |
|
177 | - self::doRemove([$tmpName], true); |
|
178 | - } catch (IOException $e) { |
|
179 | - } |
|
180 | - } |
|
181 | - |
|
182 | - if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { |
|
183 | - $origFile = $file; |
|
184 | - $file = $tmpName; |
|
185 | - } else { |
|
186 | - $origFile = null; |
|
187 | - } |
|
188 | - } |
|
189 | - |
|
190 | - $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); |
|
191 | - self::doRemove(iterator_to_array($files, true), true); |
|
192 | - |
|
193 | - if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { |
|
194 | - $lastError = self::$lastError; |
|
195 | - |
|
196 | - if (null !== $origFile && self::box('rename', $file, $origFile)) { |
|
197 | - $file = $origFile; |
|
198 | - } |
|
199 | - |
|
200 | - throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError); |
|
201 | - } |
|
202 | - } elseif (!self::box('unlink', $file) && (str_contains(self::$lastError, 'Permission denied') || file_exists($file))) { |
|
203 | - throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError); |
|
204 | - } |
|
205 | - } |
|
206 | - } |
|
207 | - |
|
208 | - /** |
|
209 | - * Change mode for an array of files or directories. |
|
210 | - * |
|
211 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change mode |
|
212 | - * @param int $mode The new mode (octal) |
|
213 | - * @param int $umask The mode mask (octal) |
|
214 | - * @param bool $recursive Whether change the mod recursively or not |
|
215 | - * |
|
216 | - * @throws IOException When the change fails |
|
217 | - */ |
|
218 | - public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false) |
|
219 | - { |
|
220 | - foreach ($this->toIterable($files) as $file) { |
|
221 | - if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) { |
|
222 | - throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); |
|
223 | - } |
|
224 | - if ($recursive && is_dir($file) && !is_link($file)) { |
|
225 | - $this->chmod(new \FilesystemIterator($file), $mode, $umask, true); |
|
226 | - } |
|
227 | - } |
|
228 | - } |
|
229 | - |
|
230 | - /** |
|
231 | - * Change the owner of an array of files or directories. |
|
232 | - * |
|
233 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change owner |
|
234 | - * @param string|int $user A user name or number |
|
235 | - * @param bool $recursive Whether change the owner recursively or not |
|
236 | - * |
|
237 | - * @throws IOException When the change fails |
|
238 | - */ |
|
239 | - public function chown($files, $user, bool $recursive = false) |
|
240 | - { |
|
241 | - foreach ($this->toIterable($files) as $file) { |
|
242 | - if ($recursive && is_dir($file) && !is_link($file)) { |
|
243 | - $this->chown(new \FilesystemIterator($file), $user, true); |
|
244 | - } |
|
245 | - if (is_link($file) && \function_exists('lchown')) { |
|
246 | - if (!self::box('lchown', $file, $user)) { |
|
247 | - throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|
248 | - } |
|
249 | - } else { |
|
250 | - if (!self::box('chown', $file, $user)) { |
|
251 | - throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|
252 | - } |
|
253 | - } |
|
254 | - } |
|
255 | - } |
|
256 | - |
|
257 | - /** |
|
258 | - * Change the group of an array of files or directories. |
|
259 | - * |
|
260 | - * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change group |
|
261 | - * @param string|int $group A group name or number |
|
262 | - * @param bool $recursive Whether change the group recursively or not |
|
263 | - * |
|
264 | - * @throws IOException When the change fails |
|
265 | - */ |
|
266 | - public function chgrp($files, $group, bool $recursive = false) |
|
267 | - { |
|
268 | - foreach ($this->toIterable($files) as $file) { |
|
269 | - if ($recursive && is_dir($file) && !is_link($file)) { |
|
270 | - $this->chgrp(new \FilesystemIterator($file), $group, true); |
|
271 | - } |
|
272 | - if (is_link($file) && \function_exists('lchgrp')) { |
|
273 | - if (!self::box('lchgrp', $file, $group)) { |
|
274 | - throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|
275 | - } |
|
276 | - } else { |
|
277 | - if (!self::box('chgrp', $file, $group)) { |
|
278 | - throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|
279 | - } |
|
280 | - } |
|
281 | - } |
|
282 | - } |
|
283 | - |
|
284 | - /** |
|
285 | - * Renames a file or a directory. |
|
286 | - * |
|
287 | - * @throws IOException When target file or directory already exists |
|
288 | - * @throws IOException When origin cannot be renamed |
|
289 | - */ |
|
290 | - public function rename(string $origin, string $target, bool $overwrite = false) |
|
291 | - { |
|
292 | - // we check that target does not exist |
|
293 | - if (!$overwrite && $this->isReadable($target)) { |
|
294 | - throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); |
|
295 | - } |
|
296 | - |
|
297 | - if (!self::box('rename', $origin, $target)) { |
|
298 | - if (is_dir($origin)) { |
|
299 | - // See https://bugs.php.net/54097 & https://php.net/rename#113943 |
|
300 | - $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]); |
|
301 | - $this->remove($origin); |
|
302 | - |
|
303 | - return; |
|
304 | - } |
|
305 | - throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); |
|
306 | - } |
|
307 | - } |
|
308 | - |
|
309 | - /** |
|
310 | - * Tells whether a file exists and is readable. |
|
311 | - * |
|
312 | - * @throws IOException When windows path is longer than 258 characters |
|
313 | - */ |
|
314 | - private function isReadable(string $filename): bool |
|
315 | - { |
|
316 | - $maxPathLength = \PHP_MAXPATHLEN - 2; |
|
317 | - |
|
318 | - if (\strlen($filename) > $maxPathLength) { |
|
319 | - throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); |
|
320 | - } |
|
321 | - |
|
322 | - return is_readable($filename); |
|
323 | - } |
|
324 | - |
|
325 | - /** |
|
326 | - * Creates a symbolic link or copy a directory. |
|
327 | - * |
|
328 | - * @throws IOException When symlink fails |
|
329 | - */ |
|
330 | - public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false) |
|
331 | - { |
|
332 | - if ('\\' === \DIRECTORY_SEPARATOR) { |
|
333 | - $originDir = strtr($originDir, '/', '\\'); |
|
334 | - $targetDir = strtr($targetDir, '/', '\\'); |
|
335 | - |
|
336 | - if ($copyOnWindows) { |
|
337 | - $this->mirror($originDir, $targetDir); |
|
338 | - |
|
339 | - return; |
|
340 | - } |
|
341 | - } |
|
342 | - |
|
343 | - $this->mkdir(\dirname($targetDir)); |
|
344 | - |
|
345 | - if (is_link($targetDir)) { |
|
346 | - if (readlink($targetDir) === $originDir) { |
|
347 | - return; |
|
348 | - } |
|
349 | - $this->remove($targetDir); |
|
350 | - } |
|
351 | - |
|
352 | - if (!self::box('symlink', $originDir, $targetDir)) { |
|
353 | - $this->linkException($originDir, $targetDir, 'symbolic'); |
|
354 | - } |
|
355 | - } |
|
356 | - |
|
357 | - /** |
|
358 | - * Creates a hard link, or several hard links to a file. |
|
359 | - * |
|
360 | - * @param string|string[] $targetFiles The target file(s) |
|
361 | - * |
|
362 | - * @throws FileNotFoundException When original file is missing or not a file |
|
363 | - * @throws IOException When link fails, including if link already exists |
|
364 | - */ |
|
365 | - public function hardlink(string $originFile, $targetFiles) |
|
366 | - { |
|
367 | - if (!$this->exists($originFile)) { |
|
368 | - throw new FileNotFoundException(null, 0, null, $originFile); |
|
369 | - } |
|
370 | - |
|
371 | - if (!is_file($originFile)) { |
|
372 | - throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile)); |
|
373 | - } |
|
374 | - |
|
375 | - foreach ($this->toIterable($targetFiles) as $targetFile) { |
|
376 | - if (is_file($targetFile)) { |
|
377 | - if (fileinode($originFile) === fileinode($targetFile)) { |
|
378 | - continue; |
|
379 | - } |
|
380 | - $this->remove($targetFile); |
|
381 | - } |
|
382 | - |
|
383 | - if (!self::box('link', $originFile, $targetFile)) { |
|
384 | - $this->linkException($originFile, $targetFile, 'hard'); |
|
385 | - } |
|
386 | - } |
|
387 | - } |
|
388 | - |
|
389 | - /** |
|
390 | - * @param string $linkType Name of the link type, typically 'symbolic' or 'hard' |
|
391 | - */ |
|
392 | - private function linkException(string $origin, string $target, string $linkType) |
|
393 | - { |
|
394 | - if (self::$lastError) { |
|
395 | - if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) { |
|
396 | - throw new IOException(sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target); |
|
397 | - } |
|
398 | - } |
|
399 | - throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); |
|
400 | - } |
|
401 | - |
|
402 | - /** |
|
403 | - * Resolves links in paths. |
|
404 | - * |
|
405 | - * With $canonicalize = false (default) |
|
406 | - * - if $path does not exist or is not a link, returns null |
|
407 | - * - if $path is a link, returns the next direct target of the link without considering the existence of the target |
|
408 | - * |
|
409 | - * With $canonicalize = true |
|
410 | - * - if $path does not exist, returns null |
|
411 | - * - if $path exists, returns its absolute fully resolved final version |
|
412 | - * |
|
413 | - * @return string|null |
|
414 | - */ |
|
415 | - public function readlink(string $path, bool $canonicalize = false) |
|
416 | - { |
|
417 | - if (!$canonicalize && !is_link($path)) { |
|
418 | - return null; |
|
419 | - } |
|
420 | - |
|
421 | - if ($canonicalize) { |
|
422 | - if (!$this->exists($path)) { |
|
423 | - return null; |
|
424 | - } |
|
425 | - |
|
426 | - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70410) { |
|
427 | - $path = readlink($path); |
|
428 | - } |
|
429 | - |
|
430 | - return realpath($path); |
|
431 | - } |
|
432 | - |
|
433 | - if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) { |
|
434 | - return realpath($path); |
|
435 | - } |
|
436 | - |
|
437 | - return readlink($path); |
|
438 | - } |
|
439 | - |
|
440 | - /** |
|
441 | - * Given an existing path, convert it to a path relative to a given starting path. |
|
442 | - * |
|
443 | - * @return string Path of target relative to starting path |
|
444 | - */ |
|
445 | - public function makePathRelative(string $endPath, string $startPath) |
|
446 | - { |
|
447 | - if (!$this->isAbsolutePath($startPath)) { |
|
448 | - throw new InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath)); |
|
449 | - } |
|
450 | - |
|
451 | - if (!$this->isAbsolutePath($endPath)) { |
|
452 | - throw new InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath)); |
|
453 | - } |
|
454 | - |
|
455 | - // Normalize separators on Windows |
|
456 | - if ('\\' === \DIRECTORY_SEPARATOR) { |
|
457 | - $endPath = str_replace('\\', '/', $endPath); |
|
458 | - $startPath = str_replace('\\', '/', $startPath); |
|
459 | - } |
|
460 | - |
|
461 | - $splitDriveLetter = function ($path) { |
|
462 | - return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) |
|
463 | - ? [substr($path, 2), strtoupper($path[0])] |
|
464 | - : [$path, null]; |
|
465 | - }; |
|
466 | - |
|
467 | - $splitPath = function ($path) { |
|
468 | - $result = []; |
|
469 | - |
|
470 | - foreach (explode('/', trim($path, '/')) as $segment) { |
|
471 | - if ('..' === $segment) { |
|
472 | - array_pop($result); |
|
473 | - } elseif ('.' !== $segment && '' !== $segment) { |
|
474 | - $result[] = $segment; |
|
475 | - } |
|
476 | - } |
|
477 | - |
|
478 | - return $result; |
|
479 | - }; |
|
480 | - |
|
481 | - [$endPath, $endDriveLetter] = $splitDriveLetter($endPath); |
|
482 | - [$startPath, $startDriveLetter] = $splitDriveLetter($startPath); |
|
483 | - |
|
484 | - $startPathArr = $splitPath($startPath); |
|
485 | - $endPathArr = $splitPath($endPath); |
|
486 | - |
|
487 | - if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) { |
|
488 | - // End path is on another drive, so no relative path exists |
|
489 | - return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : ''); |
|
490 | - } |
|
491 | - |
|
492 | - // Find for which directory the common path stops |
|
493 | - $index = 0; |
|
494 | - while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) { |
|
495 | - ++$index; |
|
496 | - } |
|
497 | - |
|
498 | - // Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels) |
|
499 | - if (1 === \count($startPathArr) && '' === $startPathArr[0]) { |
|
500 | - $depth = 0; |
|
501 | - } else { |
|
502 | - $depth = \count($startPathArr) - $index; |
|
503 | - } |
|
504 | - |
|
505 | - // Repeated "../" for each level need to reach the common path |
|
506 | - $traverser = str_repeat('../', $depth); |
|
507 | - |
|
508 | - $endPathRemainder = implode('/', \array_slice($endPathArr, $index)); |
|
509 | - |
|
510 | - // Construct $endPath from traversing to the common path, then to the remaining $endPath |
|
511 | - $relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : ''); |
|
512 | - |
|
513 | - return '' === $relativePath ? './' : $relativePath; |
|
514 | - } |
|
515 | - |
|
516 | - /** |
|
517 | - * Mirrors a directory to another. |
|
518 | - * |
|
519 | - * Copies files and directories from the origin directory into the target directory. By default: |
|
520 | - * |
|
521 | - * - existing files in the target directory will be overwritten, except if they are newer (see the `override` option) |
|
522 | - * - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option) |
|
523 | - * |
|
524 | - * @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created |
|
525 | - * @param array $options An array of boolean options |
|
526 | - * Valid options are: |
|
527 | - * - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false) |
|
528 | - * - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false) |
|
529 | - * - $options['delete'] Whether to delete files that are not in the source directory (defaults to false) |
|
530 | - * |
|
531 | - * @throws IOException When file type is unknown |
|
532 | - */ |
|
533 | - public function mirror(string $originDir, string $targetDir, \Traversable $iterator = null, array $options = []) |
|
534 | - { |
|
535 | - $targetDir = rtrim($targetDir, '/\\'); |
|
536 | - $originDir = rtrim($originDir, '/\\'); |
|
537 | - $originDirLen = \strlen($originDir); |
|
538 | - |
|
539 | - if (!$this->exists($originDir)) { |
|
540 | - throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); |
|
541 | - } |
|
542 | - |
|
543 | - // Iterate in destination folder to remove obsolete entries |
|
544 | - if ($this->exists($targetDir) && isset($options['delete']) && $options['delete']) { |
|
545 | - $deleteIterator = $iterator; |
|
546 | - if (null === $deleteIterator) { |
|
547 | - $flags = \FilesystemIterator::SKIP_DOTS; |
|
548 | - $deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST); |
|
549 | - } |
|
550 | - $targetDirLen = \strlen($targetDir); |
|
551 | - foreach ($deleteIterator as $file) { |
|
552 | - $origin = $originDir.substr($file->getPathname(), $targetDirLen); |
|
553 | - if (!$this->exists($origin)) { |
|
554 | - $this->remove($file); |
|
555 | - } |
|
556 | - } |
|
557 | - } |
|
558 | - |
|
559 | - $copyOnWindows = $options['copy_on_windows'] ?? false; |
|
560 | - |
|
561 | - if (null === $iterator) { |
|
562 | - $flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS; |
|
563 | - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST); |
|
564 | - } |
|
565 | - |
|
566 | - $this->mkdir($targetDir); |
|
567 | - $filesCreatedWhileMirroring = []; |
|
568 | - |
|
569 | - foreach ($iterator as $file) { |
|
570 | - if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) { |
|
571 | - continue; |
|
572 | - } |
|
573 | - |
|
574 | - $target = $targetDir.substr($file->getPathname(), $originDirLen); |
|
575 | - $filesCreatedWhileMirroring[$target] = true; |
|
576 | - |
|
577 | - if (!$copyOnWindows && is_link($file)) { |
|
578 | - $this->symlink($file->getLinkTarget(), $target); |
|
579 | - } elseif (is_dir($file)) { |
|
580 | - $this->mkdir($target); |
|
581 | - } elseif (is_file($file)) { |
|
582 | - $this->copy($file, $target, $options['override'] ?? false); |
|
583 | - } else { |
|
584 | - throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); |
|
585 | - } |
|
586 | - } |
|
587 | - } |
|
588 | - |
|
589 | - /** |
|
590 | - * Returns whether the file path is an absolute path. |
|
591 | - * |
|
592 | - * @return bool |
|
593 | - */ |
|
594 | - public function isAbsolutePath(string $file) |
|
595 | - { |
|
596 | - return '' !== $file && (strspn($file, '/\\', 0, 1) |
|
597 | - || (\strlen($file) > 3 && ctype_alpha($file[0]) |
|
598 | - && ':' === $file[1] |
|
599 | - && strspn($file, '/\\', 2, 1) |
|
600 | - ) |
|
601 | - || null !== parse_url($file, \PHP_URL_SCHEME) |
|
602 | - ); |
|
603 | - } |
|
604 | - |
|
605 | - /** |
|
606 | - * Creates a temporary file with support for custom stream wrappers. |
|
607 | - * |
|
608 | - * @param string $prefix The prefix of the generated temporary filename |
|
609 | - * Note: Windows uses only the first three characters of prefix |
|
610 | - * @param string $suffix The suffix of the generated temporary filename |
|
611 | - * |
|
612 | - * @return string The new temporary filename (with path), or throw an exception on failure |
|
613 | - */ |
|
614 | - public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/) |
|
615 | - { |
|
616 | - $suffix = \func_num_args() > 2 ? func_get_arg(2) : ''; |
|
617 | - [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir); |
|
618 | - |
|
619 | - // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem |
|
620 | - if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) { |
|
621 | - // If tempnam failed or no scheme return the filename otherwise prepend the scheme |
|
622 | - if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) { |
|
623 | - if (null !== $scheme && 'gs' !== $scheme) { |
|
624 | - return $scheme.'://'.$tmpFile; |
|
625 | - } |
|
626 | - |
|
627 | - return $tmpFile; |
|
628 | - } |
|
629 | - |
|
630 | - throw new IOException('A temporary file could not be created: '.self::$lastError); |
|
631 | - } |
|
632 | - |
|
633 | - // Loop until we create a valid temp file or have reached 10 attempts |
|
634 | - for ($i = 0; $i < 10; ++$i) { |
|
635 | - // Create a unique filename |
|
636 | - $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true).$suffix; |
|
637 | - |
|
638 | - // Use fopen instead of file_exists as some streams do not support stat |
|
639 | - // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability |
|
640 | - if (!$handle = self::box('fopen', $tmpFile, 'x+')) { |
|
641 | - continue; |
|
642 | - } |
|
643 | - |
|
644 | - // Close the file if it was successfully opened |
|
645 | - self::box('fclose', $handle); |
|
646 | - |
|
647 | - return $tmpFile; |
|
648 | - } |
|
649 | - |
|
650 | - throw new IOException('A temporary file could not be created: '.self::$lastError); |
|
651 | - } |
|
652 | - |
|
653 | - /** |
|
654 | - * Atomically dumps content into a file. |
|
655 | - * |
|
656 | - * @param string|resource $content The data to write into the file |
|
657 | - * |
|
658 | - * @throws IOException if the file cannot be written to |
|
659 | - */ |
|
660 | - public function dumpFile(string $filename, $content) |
|
661 | - { |
|
662 | - if (\is_array($content)) { |
|
663 | - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|
664 | - } |
|
665 | - |
|
666 | - $dir = \dirname($filename); |
|
667 | - |
|
668 | - if (!is_dir($dir)) { |
|
669 | - $this->mkdir($dir); |
|
670 | - } |
|
671 | - |
|
672 | - // Will create a temp file with 0600 access rights |
|
673 | - // when the filesystem supports chmod. |
|
674 | - $tmpFile = $this->tempnam($dir, basename($filename)); |
|
675 | - |
|
676 | - try { |
|
677 | - if (false === self::box('file_put_contents', $tmpFile, $content)) { |
|
678 | - throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|
679 | - } |
|
680 | - |
|
681 | - self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask()); |
|
682 | - |
|
683 | - $this->rename($tmpFile, $filename, true); |
|
684 | - } finally { |
|
685 | - if (file_exists($tmpFile)) { |
|
686 | - self::box('unlink', $tmpFile); |
|
687 | - } |
|
688 | - } |
|
689 | - } |
|
690 | - |
|
691 | - /** |
|
692 | - * Appends content to an existing file. |
|
693 | - * |
|
694 | - * @param string|resource $content The content to append |
|
695 | - * |
|
696 | - * @throws IOException If the file is not writable |
|
697 | - */ |
|
698 | - public function appendToFile(string $filename, $content) |
|
699 | - { |
|
700 | - if (\is_array($content)) { |
|
701 | - throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|
702 | - } |
|
703 | - |
|
704 | - $dir = \dirname($filename); |
|
705 | - |
|
706 | - if (!is_dir($dir)) { |
|
707 | - $this->mkdir($dir); |
|
708 | - } |
|
709 | - |
|
710 | - if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND)) { |
|
711 | - throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|
712 | - } |
|
713 | - } |
|
714 | - |
|
715 | - private function toIterable($files): iterable |
|
716 | - { |
|
717 | - return is_iterable($files) ? $files : [$files]; |
|
718 | - } |
|
719 | - |
|
720 | - /** |
|
721 | - * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]). |
|
722 | - */ |
|
723 | - private function getSchemeAndHierarchy(string $filename): array |
|
724 | - { |
|
725 | - $components = explode('://', $filename, 2); |
|
726 | - |
|
727 | - return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]]; |
|
728 | - } |
|
729 | - |
|
730 | - /** |
|
731 | - * @param mixed ...$args |
|
732 | - * |
|
733 | - * @return mixed |
|
734 | - */ |
|
735 | - private static function box(callable $func, ...$args) |
|
736 | - { |
|
737 | - self::$lastError = null; |
|
738 | - set_error_handler(__CLASS__.'::handleError'); |
|
739 | - try { |
|
740 | - $result = $func(...$args); |
|
741 | - restore_error_handler(); |
|
742 | - |
|
743 | - return $result; |
|
744 | - } catch (\Throwable $e) { |
|
745 | - } |
|
746 | - restore_error_handler(); |
|
747 | - |
|
748 | - throw $e; |
|
749 | - } |
|
750 | - |
|
751 | - /** |
|
752 | - * @internal |
|
753 | - */ |
|
754 | - public static function handleError(int $type, string $msg) |
|
755 | - { |
|
756 | - self::$lastError = $msg; |
|
757 | - } |
|
25 | + private static $lastError; |
|
26 | + |
|
27 | + /** |
|
28 | + * Copies a file. |
|
29 | + * |
|
30 | + * If the target file is older than the origin file, it's always overwritten. |
|
31 | + * If the target file is newer, it is overwritten only when the |
|
32 | + * $overwriteNewerFiles option is set to true. |
|
33 | + * |
|
34 | + * @throws FileNotFoundException When originFile doesn't exist |
|
35 | + * @throws IOException When copy fails |
|
36 | + */ |
|
37 | + public function copy(string $originFile, string $targetFile, bool $overwriteNewerFiles = false) |
|
38 | + { |
|
39 | + $originIsLocal = stream_is_local($originFile) || 0 === stripos($originFile, 'file://'); |
|
40 | + if ($originIsLocal && !is_file($originFile)) { |
|
41 | + throw new FileNotFoundException(sprintf('Failed to copy "%s" because file does not exist.', $originFile), 0, null, $originFile); |
|
42 | + } |
|
43 | + |
|
44 | + $this->mkdir(\dirname($targetFile)); |
|
45 | + |
|
46 | + $doCopy = true; |
|
47 | + if (!$overwriteNewerFiles && null === parse_url($originFile, \PHP_URL_HOST) && is_file($targetFile)) { |
|
48 | + $doCopy = filemtime($originFile) > filemtime($targetFile); |
|
49 | + } |
|
50 | + |
|
51 | + if ($doCopy) { |
|
52 | + // https://bugs.php.net/64634 |
|
53 | + if (!$source = self::box('fopen', $originFile, 'r')) { |
|
54 | + throw new IOException(sprintf('Failed to copy "%s" to "%s" because source file could not be opened for reading: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|
55 | + } |
|
56 | + |
|
57 | + // Stream context created to allow files overwrite when using FTP stream wrapper - disabled by default |
|
58 | + if (!$target = self::box('fopen', $targetFile, 'w', false, stream_context_create(['ftp' => ['overwrite' => true]]))) { |
|
59 | + throw new IOException(sprintf('Failed to copy "%s" to "%s" because target file could not be opened for writing: ', $originFile, $targetFile).self::$lastError, 0, null, $originFile); |
|
60 | + } |
|
61 | + |
|
62 | + $bytesCopied = stream_copy_to_stream($source, $target); |
|
63 | + fclose($source); |
|
64 | + fclose($target); |
|
65 | + unset($source, $target); |
|
66 | + |
|
67 | + if (!is_file($targetFile)) { |
|
68 | + throw new IOException(sprintf('Failed to copy "%s" to "%s".', $originFile, $targetFile), 0, null, $originFile); |
|
69 | + } |
|
70 | + |
|
71 | + if ($originIsLocal) { |
|
72 | + // Like `cp`, preserve executable permission bits |
|
73 | + self::box('chmod', $targetFile, fileperms($targetFile) | (fileperms($originFile) & 0111)); |
|
74 | + |
|
75 | + if ($bytesCopied !== $bytesOrigin = filesize($originFile)) { |
|
76 | + throw new IOException(sprintf('Failed to copy the whole content of "%s" to "%s" (%g of %g bytes copied).', $originFile, $targetFile, $bytesCopied, $bytesOrigin), 0, null, $originFile); |
|
77 | + } |
|
78 | + } |
|
79 | + } |
|
80 | + } |
|
81 | + |
|
82 | + /** |
|
83 | + * Creates a directory recursively. |
|
84 | + * |
|
85 | + * @param string|iterable $dirs The directory path |
|
86 | + * |
|
87 | + * @throws IOException On any directory creation failure |
|
88 | + */ |
|
89 | + public function mkdir($dirs, int $mode = 0777) |
|
90 | + { |
|
91 | + foreach ($this->toIterable($dirs) as $dir) { |
|
92 | + if (is_dir($dir)) { |
|
93 | + continue; |
|
94 | + } |
|
95 | + |
|
96 | + if (!self::box('mkdir', $dir, $mode, true) && !is_dir($dir)) { |
|
97 | + throw new IOException(sprintf('Failed to create "%s": ', $dir).self::$lastError, 0, null, $dir); |
|
98 | + } |
|
99 | + } |
|
100 | + } |
|
101 | + |
|
102 | + /** |
|
103 | + * Checks the existence of files or directories. |
|
104 | + * |
|
105 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to check |
|
106 | + * |
|
107 | + * @return bool true if the file exists, false otherwise |
|
108 | + */ |
|
109 | + public function exists($files) |
|
110 | + { |
|
111 | + $maxPathLength = \PHP_MAXPATHLEN - 2; |
|
112 | + |
|
113 | + foreach ($this->toIterable($files) as $file) { |
|
114 | + if (\strlen($file) > $maxPathLength) { |
|
115 | + throw new IOException(sprintf('Could not check if file exist because path length exceeds %d characters.', $maxPathLength), 0, null, $file); |
|
116 | + } |
|
117 | + |
|
118 | + if (!file_exists($file)) { |
|
119 | + return false; |
|
120 | + } |
|
121 | + } |
|
122 | + |
|
123 | + return true; |
|
124 | + } |
|
125 | + |
|
126 | + /** |
|
127 | + * Sets access and modification time of file. |
|
128 | + * |
|
129 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to create |
|
130 | + * @param int|null $time The touch time as a Unix timestamp, if not supplied the current system time is used |
|
131 | + * @param int|null $atime The access time as a Unix timestamp, if not supplied the current system time is used |
|
132 | + * |
|
133 | + * @throws IOException When touch fails |
|
134 | + */ |
|
135 | + public function touch($files, int $time = null, int $atime = null) |
|
136 | + { |
|
137 | + foreach ($this->toIterable($files) as $file) { |
|
138 | + if (!($time ? self::box('touch', $file, $time, $atime) : self::box('touch', $file))) { |
|
139 | + throw new IOException(sprintf('Failed to touch "%s": ', $file).self::$lastError, 0, null, $file); |
|
140 | + } |
|
141 | + } |
|
142 | + } |
|
143 | + |
|
144 | + /** |
|
145 | + * Removes files or directories. |
|
146 | + * |
|
147 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to remove |
|
148 | + * |
|
149 | + * @throws IOException When removal fails |
|
150 | + */ |
|
151 | + public function remove($files) |
|
152 | + { |
|
153 | + if ($files instanceof \Traversable) { |
|
154 | + $files = iterator_to_array($files, false); |
|
155 | + } elseif (!\is_array($files)) { |
|
156 | + $files = [$files]; |
|
157 | + } |
|
158 | + |
|
159 | + self::doRemove($files, false); |
|
160 | + } |
|
161 | + |
|
162 | + private static function doRemove(array $files, bool $isRecursive): void |
|
163 | + { |
|
164 | + $files = array_reverse($files); |
|
165 | + foreach ($files as $file) { |
|
166 | + if (is_link($file)) { |
|
167 | + // See https://bugs.php.net/52176 |
|
168 | + if (!(self::box('unlink', $file) || '\\' !== \DIRECTORY_SEPARATOR || self::box('rmdir', $file)) && file_exists($file)) { |
|
169 | + throw new IOException(sprintf('Failed to remove symlink "%s": ', $file).self::$lastError); |
|
170 | + } |
|
171 | + } elseif (is_dir($file)) { |
|
172 | + if (!$isRecursive) { |
|
173 | + $tmpName = \dirname(realpath($file)).'/.'.strrev(strtr(base64_encode(random_bytes(2)), '/=', '-.')); |
|
174 | + |
|
175 | + if (file_exists($tmpName)) { |
|
176 | + try { |
|
177 | + self::doRemove([$tmpName], true); |
|
178 | + } catch (IOException $e) { |
|
179 | + } |
|
180 | + } |
|
181 | + |
|
182 | + if (!file_exists($tmpName) && self::box('rename', $file, $tmpName)) { |
|
183 | + $origFile = $file; |
|
184 | + $file = $tmpName; |
|
185 | + } else { |
|
186 | + $origFile = null; |
|
187 | + } |
|
188 | + } |
|
189 | + |
|
190 | + $files = new \FilesystemIterator($file, \FilesystemIterator::CURRENT_AS_PATHNAME | \FilesystemIterator::SKIP_DOTS); |
|
191 | + self::doRemove(iterator_to_array($files, true), true); |
|
192 | + |
|
193 | + if (!self::box('rmdir', $file) && file_exists($file) && !$isRecursive) { |
|
194 | + $lastError = self::$lastError; |
|
195 | + |
|
196 | + if (null !== $origFile && self::box('rename', $file, $origFile)) { |
|
197 | + $file = $origFile; |
|
198 | + } |
|
199 | + |
|
200 | + throw new IOException(sprintf('Failed to remove directory "%s": ', $file).$lastError); |
|
201 | + } |
|
202 | + } elseif (!self::box('unlink', $file) && (str_contains(self::$lastError, 'Permission denied') || file_exists($file))) { |
|
203 | + throw new IOException(sprintf('Failed to remove file "%s": ', $file).self::$lastError); |
|
204 | + } |
|
205 | + } |
|
206 | + } |
|
207 | + |
|
208 | + /** |
|
209 | + * Change mode for an array of files or directories. |
|
210 | + * |
|
211 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change mode |
|
212 | + * @param int $mode The new mode (octal) |
|
213 | + * @param int $umask The mode mask (octal) |
|
214 | + * @param bool $recursive Whether change the mod recursively or not |
|
215 | + * |
|
216 | + * @throws IOException When the change fails |
|
217 | + */ |
|
218 | + public function chmod($files, int $mode, int $umask = 0000, bool $recursive = false) |
|
219 | + { |
|
220 | + foreach ($this->toIterable($files) as $file) { |
|
221 | + if ((\PHP_VERSION_ID < 80000 || \is_int($mode)) && !self::box('chmod', $file, $mode & ~$umask)) { |
|
222 | + throw new IOException(sprintf('Failed to chmod file "%s": ', $file).self::$lastError, 0, null, $file); |
|
223 | + } |
|
224 | + if ($recursive && is_dir($file) && !is_link($file)) { |
|
225 | + $this->chmod(new \FilesystemIterator($file), $mode, $umask, true); |
|
226 | + } |
|
227 | + } |
|
228 | + } |
|
229 | + |
|
230 | + /** |
|
231 | + * Change the owner of an array of files or directories. |
|
232 | + * |
|
233 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change owner |
|
234 | + * @param string|int $user A user name or number |
|
235 | + * @param bool $recursive Whether change the owner recursively or not |
|
236 | + * |
|
237 | + * @throws IOException When the change fails |
|
238 | + */ |
|
239 | + public function chown($files, $user, bool $recursive = false) |
|
240 | + { |
|
241 | + foreach ($this->toIterable($files) as $file) { |
|
242 | + if ($recursive && is_dir($file) && !is_link($file)) { |
|
243 | + $this->chown(new \FilesystemIterator($file), $user, true); |
|
244 | + } |
|
245 | + if (is_link($file) && \function_exists('lchown')) { |
|
246 | + if (!self::box('lchown', $file, $user)) { |
|
247 | + throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|
248 | + } |
|
249 | + } else { |
|
250 | + if (!self::box('chown', $file, $user)) { |
|
251 | + throw new IOException(sprintf('Failed to chown file "%s": ', $file).self::$lastError, 0, null, $file); |
|
252 | + } |
|
253 | + } |
|
254 | + } |
|
255 | + } |
|
256 | + |
|
257 | + /** |
|
258 | + * Change the group of an array of files or directories. |
|
259 | + * |
|
260 | + * @param string|iterable $files A filename, an array of files, or a \Traversable instance to change group |
|
261 | + * @param string|int $group A group name or number |
|
262 | + * @param bool $recursive Whether change the group recursively or not |
|
263 | + * |
|
264 | + * @throws IOException When the change fails |
|
265 | + */ |
|
266 | + public function chgrp($files, $group, bool $recursive = false) |
|
267 | + { |
|
268 | + foreach ($this->toIterable($files) as $file) { |
|
269 | + if ($recursive && is_dir($file) && !is_link($file)) { |
|
270 | + $this->chgrp(new \FilesystemIterator($file), $group, true); |
|
271 | + } |
|
272 | + if (is_link($file) && \function_exists('lchgrp')) { |
|
273 | + if (!self::box('lchgrp', $file, $group)) { |
|
274 | + throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|
275 | + } |
|
276 | + } else { |
|
277 | + if (!self::box('chgrp', $file, $group)) { |
|
278 | + throw new IOException(sprintf('Failed to chgrp file "%s": ', $file).self::$lastError, 0, null, $file); |
|
279 | + } |
|
280 | + } |
|
281 | + } |
|
282 | + } |
|
283 | + |
|
284 | + /** |
|
285 | + * Renames a file or a directory. |
|
286 | + * |
|
287 | + * @throws IOException When target file or directory already exists |
|
288 | + * @throws IOException When origin cannot be renamed |
|
289 | + */ |
|
290 | + public function rename(string $origin, string $target, bool $overwrite = false) |
|
291 | + { |
|
292 | + // we check that target does not exist |
|
293 | + if (!$overwrite && $this->isReadable($target)) { |
|
294 | + throw new IOException(sprintf('Cannot rename because the target "%s" already exists.', $target), 0, null, $target); |
|
295 | + } |
|
296 | + |
|
297 | + if (!self::box('rename', $origin, $target)) { |
|
298 | + if (is_dir($origin)) { |
|
299 | + // See https://bugs.php.net/54097 & https://php.net/rename#113943 |
|
300 | + $this->mirror($origin, $target, null, ['override' => $overwrite, 'delete' => $overwrite]); |
|
301 | + $this->remove($origin); |
|
302 | + |
|
303 | + return; |
|
304 | + } |
|
305 | + throw new IOException(sprintf('Cannot rename "%s" to "%s": ', $origin, $target).self::$lastError, 0, null, $target); |
|
306 | + } |
|
307 | + } |
|
308 | + |
|
309 | + /** |
|
310 | + * Tells whether a file exists and is readable. |
|
311 | + * |
|
312 | + * @throws IOException When windows path is longer than 258 characters |
|
313 | + */ |
|
314 | + private function isReadable(string $filename): bool |
|
315 | + { |
|
316 | + $maxPathLength = \PHP_MAXPATHLEN - 2; |
|
317 | + |
|
318 | + if (\strlen($filename) > $maxPathLength) { |
|
319 | + throw new IOException(sprintf('Could not check if file is readable because path length exceeds %d characters.', $maxPathLength), 0, null, $filename); |
|
320 | + } |
|
321 | + |
|
322 | + return is_readable($filename); |
|
323 | + } |
|
324 | + |
|
325 | + /** |
|
326 | + * Creates a symbolic link or copy a directory. |
|
327 | + * |
|
328 | + * @throws IOException When symlink fails |
|
329 | + */ |
|
330 | + public function symlink(string $originDir, string $targetDir, bool $copyOnWindows = false) |
|
331 | + { |
|
332 | + if ('\\' === \DIRECTORY_SEPARATOR) { |
|
333 | + $originDir = strtr($originDir, '/', '\\'); |
|
334 | + $targetDir = strtr($targetDir, '/', '\\'); |
|
335 | + |
|
336 | + if ($copyOnWindows) { |
|
337 | + $this->mirror($originDir, $targetDir); |
|
338 | + |
|
339 | + return; |
|
340 | + } |
|
341 | + } |
|
342 | + |
|
343 | + $this->mkdir(\dirname($targetDir)); |
|
344 | + |
|
345 | + if (is_link($targetDir)) { |
|
346 | + if (readlink($targetDir) === $originDir) { |
|
347 | + return; |
|
348 | + } |
|
349 | + $this->remove($targetDir); |
|
350 | + } |
|
351 | + |
|
352 | + if (!self::box('symlink', $originDir, $targetDir)) { |
|
353 | + $this->linkException($originDir, $targetDir, 'symbolic'); |
|
354 | + } |
|
355 | + } |
|
356 | + |
|
357 | + /** |
|
358 | + * Creates a hard link, or several hard links to a file. |
|
359 | + * |
|
360 | + * @param string|string[] $targetFiles The target file(s) |
|
361 | + * |
|
362 | + * @throws FileNotFoundException When original file is missing or not a file |
|
363 | + * @throws IOException When link fails, including if link already exists |
|
364 | + */ |
|
365 | + public function hardlink(string $originFile, $targetFiles) |
|
366 | + { |
|
367 | + if (!$this->exists($originFile)) { |
|
368 | + throw new FileNotFoundException(null, 0, null, $originFile); |
|
369 | + } |
|
370 | + |
|
371 | + if (!is_file($originFile)) { |
|
372 | + throw new FileNotFoundException(sprintf('Origin file "%s" is not a file.', $originFile)); |
|
373 | + } |
|
374 | + |
|
375 | + foreach ($this->toIterable($targetFiles) as $targetFile) { |
|
376 | + if (is_file($targetFile)) { |
|
377 | + if (fileinode($originFile) === fileinode($targetFile)) { |
|
378 | + continue; |
|
379 | + } |
|
380 | + $this->remove($targetFile); |
|
381 | + } |
|
382 | + |
|
383 | + if (!self::box('link', $originFile, $targetFile)) { |
|
384 | + $this->linkException($originFile, $targetFile, 'hard'); |
|
385 | + } |
|
386 | + } |
|
387 | + } |
|
388 | + |
|
389 | + /** |
|
390 | + * @param string $linkType Name of the link type, typically 'symbolic' or 'hard' |
|
391 | + */ |
|
392 | + private function linkException(string $origin, string $target, string $linkType) |
|
393 | + { |
|
394 | + if (self::$lastError) { |
|
395 | + if ('\\' === \DIRECTORY_SEPARATOR && str_contains(self::$lastError, 'error code(1314)')) { |
|
396 | + throw new IOException(sprintf('Unable to create "%s" link due to error code 1314: \'A required privilege is not held by the client\'. Do you have the required Administrator-rights?', $linkType), 0, null, $target); |
|
397 | + } |
|
398 | + } |
|
399 | + throw new IOException(sprintf('Failed to create "%s" link from "%s" to "%s": ', $linkType, $origin, $target).self::$lastError, 0, null, $target); |
|
400 | + } |
|
401 | + |
|
402 | + /** |
|
403 | + * Resolves links in paths. |
|
404 | + * |
|
405 | + * With $canonicalize = false (default) |
|
406 | + * - if $path does not exist or is not a link, returns null |
|
407 | + * - if $path is a link, returns the next direct target of the link without considering the existence of the target |
|
408 | + * |
|
409 | + * With $canonicalize = true |
|
410 | + * - if $path does not exist, returns null |
|
411 | + * - if $path exists, returns its absolute fully resolved final version |
|
412 | + * |
|
413 | + * @return string|null |
|
414 | + */ |
|
415 | + public function readlink(string $path, bool $canonicalize = false) |
|
416 | + { |
|
417 | + if (!$canonicalize && !is_link($path)) { |
|
418 | + return null; |
|
419 | + } |
|
420 | + |
|
421 | + if ($canonicalize) { |
|
422 | + if (!$this->exists($path)) { |
|
423 | + return null; |
|
424 | + } |
|
425 | + |
|
426 | + if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70410) { |
|
427 | + $path = readlink($path); |
|
428 | + } |
|
429 | + |
|
430 | + return realpath($path); |
|
431 | + } |
|
432 | + |
|
433 | + if ('\\' === \DIRECTORY_SEPARATOR && \PHP_VERSION_ID < 70400) { |
|
434 | + return realpath($path); |
|
435 | + } |
|
436 | + |
|
437 | + return readlink($path); |
|
438 | + } |
|
439 | + |
|
440 | + /** |
|
441 | + * Given an existing path, convert it to a path relative to a given starting path. |
|
442 | + * |
|
443 | + * @return string Path of target relative to starting path |
|
444 | + */ |
|
445 | + public function makePathRelative(string $endPath, string $startPath) |
|
446 | + { |
|
447 | + if (!$this->isAbsolutePath($startPath)) { |
|
448 | + throw new InvalidArgumentException(sprintf('The start path "%s" is not absolute.', $startPath)); |
|
449 | + } |
|
450 | + |
|
451 | + if (!$this->isAbsolutePath($endPath)) { |
|
452 | + throw new InvalidArgumentException(sprintf('The end path "%s" is not absolute.', $endPath)); |
|
453 | + } |
|
454 | + |
|
455 | + // Normalize separators on Windows |
|
456 | + if ('\\' === \DIRECTORY_SEPARATOR) { |
|
457 | + $endPath = str_replace('\\', '/', $endPath); |
|
458 | + $startPath = str_replace('\\', '/', $startPath); |
|
459 | + } |
|
460 | + |
|
461 | + $splitDriveLetter = function ($path) { |
|
462 | + return (\strlen($path) > 2 && ':' === $path[1] && '/' === $path[2] && ctype_alpha($path[0])) |
|
463 | + ? [substr($path, 2), strtoupper($path[0])] |
|
464 | + : [$path, null]; |
|
465 | + }; |
|
466 | + |
|
467 | + $splitPath = function ($path) { |
|
468 | + $result = []; |
|
469 | + |
|
470 | + foreach (explode('/', trim($path, '/')) as $segment) { |
|
471 | + if ('..' === $segment) { |
|
472 | + array_pop($result); |
|
473 | + } elseif ('.' !== $segment && '' !== $segment) { |
|
474 | + $result[] = $segment; |
|
475 | + } |
|
476 | + } |
|
477 | + |
|
478 | + return $result; |
|
479 | + }; |
|
480 | + |
|
481 | + [$endPath, $endDriveLetter] = $splitDriveLetter($endPath); |
|
482 | + [$startPath, $startDriveLetter] = $splitDriveLetter($startPath); |
|
483 | + |
|
484 | + $startPathArr = $splitPath($startPath); |
|
485 | + $endPathArr = $splitPath($endPath); |
|
486 | + |
|
487 | + if ($endDriveLetter && $startDriveLetter && $endDriveLetter != $startDriveLetter) { |
|
488 | + // End path is on another drive, so no relative path exists |
|
489 | + return $endDriveLetter.':/'.($endPathArr ? implode('/', $endPathArr).'/' : ''); |
|
490 | + } |
|
491 | + |
|
492 | + // Find for which directory the common path stops |
|
493 | + $index = 0; |
|
494 | + while (isset($startPathArr[$index]) && isset($endPathArr[$index]) && $startPathArr[$index] === $endPathArr[$index]) { |
|
495 | + ++$index; |
|
496 | + } |
|
497 | + |
|
498 | + // Determine how deep the start path is relative to the common path (ie, "web/bundles" = 2 levels) |
|
499 | + if (1 === \count($startPathArr) && '' === $startPathArr[0]) { |
|
500 | + $depth = 0; |
|
501 | + } else { |
|
502 | + $depth = \count($startPathArr) - $index; |
|
503 | + } |
|
504 | + |
|
505 | + // Repeated "../" for each level need to reach the common path |
|
506 | + $traverser = str_repeat('../', $depth); |
|
507 | + |
|
508 | + $endPathRemainder = implode('/', \array_slice($endPathArr, $index)); |
|
509 | + |
|
510 | + // Construct $endPath from traversing to the common path, then to the remaining $endPath |
|
511 | + $relativePath = $traverser.('' !== $endPathRemainder ? $endPathRemainder.'/' : ''); |
|
512 | + |
|
513 | + return '' === $relativePath ? './' : $relativePath; |
|
514 | + } |
|
515 | + |
|
516 | + /** |
|
517 | + * Mirrors a directory to another. |
|
518 | + * |
|
519 | + * Copies files and directories from the origin directory into the target directory. By default: |
|
520 | + * |
|
521 | + * - existing files in the target directory will be overwritten, except if they are newer (see the `override` option) |
|
522 | + * - files in the target directory that do not exist in the source directory will not be deleted (see the `delete` option) |
|
523 | + * |
|
524 | + * @param \Traversable|null $iterator Iterator that filters which files and directories to copy, if null a recursive iterator is created |
|
525 | + * @param array $options An array of boolean options |
|
526 | + * Valid options are: |
|
527 | + * - $options['override'] If true, target files newer than origin files are overwritten (see copy(), defaults to false) |
|
528 | + * - $options['copy_on_windows'] Whether to copy files instead of links on Windows (see symlink(), defaults to false) |
|
529 | + * - $options['delete'] Whether to delete files that are not in the source directory (defaults to false) |
|
530 | + * |
|
531 | + * @throws IOException When file type is unknown |
|
532 | + */ |
|
533 | + public function mirror(string $originDir, string $targetDir, \Traversable $iterator = null, array $options = []) |
|
534 | + { |
|
535 | + $targetDir = rtrim($targetDir, '/\\'); |
|
536 | + $originDir = rtrim($originDir, '/\\'); |
|
537 | + $originDirLen = \strlen($originDir); |
|
538 | + |
|
539 | + if (!$this->exists($originDir)) { |
|
540 | + throw new IOException(sprintf('The origin directory specified "%s" was not found.', $originDir), 0, null, $originDir); |
|
541 | + } |
|
542 | + |
|
543 | + // Iterate in destination folder to remove obsolete entries |
|
544 | + if ($this->exists($targetDir) && isset($options['delete']) && $options['delete']) { |
|
545 | + $deleteIterator = $iterator; |
|
546 | + if (null === $deleteIterator) { |
|
547 | + $flags = \FilesystemIterator::SKIP_DOTS; |
|
548 | + $deleteIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($targetDir, $flags), \RecursiveIteratorIterator::CHILD_FIRST); |
|
549 | + } |
|
550 | + $targetDirLen = \strlen($targetDir); |
|
551 | + foreach ($deleteIterator as $file) { |
|
552 | + $origin = $originDir.substr($file->getPathname(), $targetDirLen); |
|
553 | + if (!$this->exists($origin)) { |
|
554 | + $this->remove($file); |
|
555 | + } |
|
556 | + } |
|
557 | + } |
|
558 | + |
|
559 | + $copyOnWindows = $options['copy_on_windows'] ?? false; |
|
560 | + |
|
561 | + if (null === $iterator) { |
|
562 | + $flags = $copyOnWindows ? \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS : \FilesystemIterator::SKIP_DOTS; |
|
563 | + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($originDir, $flags), \RecursiveIteratorIterator::SELF_FIRST); |
|
564 | + } |
|
565 | + |
|
566 | + $this->mkdir($targetDir); |
|
567 | + $filesCreatedWhileMirroring = []; |
|
568 | + |
|
569 | + foreach ($iterator as $file) { |
|
570 | + if ($file->getPathname() === $targetDir || $file->getRealPath() === $targetDir || isset($filesCreatedWhileMirroring[$file->getRealPath()])) { |
|
571 | + continue; |
|
572 | + } |
|
573 | + |
|
574 | + $target = $targetDir.substr($file->getPathname(), $originDirLen); |
|
575 | + $filesCreatedWhileMirroring[$target] = true; |
|
576 | + |
|
577 | + if (!$copyOnWindows && is_link($file)) { |
|
578 | + $this->symlink($file->getLinkTarget(), $target); |
|
579 | + } elseif (is_dir($file)) { |
|
580 | + $this->mkdir($target); |
|
581 | + } elseif (is_file($file)) { |
|
582 | + $this->copy($file, $target, $options['override'] ?? false); |
|
583 | + } else { |
|
584 | + throw new IOException(sprintf('Unable to guess "%s" file type.', $file), 0, null, $file); |
|
585 | + } |
|
586 | + } |
|
587 | + } |
|
588 | + |
|
589 | + /** |
|
590 | + * Returns whether the file path is an absolute path. |
|
591 | + * |
|
592 | + * @return bool |
|
593 | + */ |
|
594 | + public function isAbsolutePath(string $file) |
|
595 | + { |
|
596 | + return '' !== $file && (strspn($file, '/\\', 0, 1) |
|
597 | + || (\strlen($file) > 3 && ctype_alpha($file[0]) |
|
598 | + && ':' === $file[1] |
|
599 | + && strspn($file, '/\\', 2, 1) |
|
600 | + ) |
|
601 | + || null !== parse_url($file, \PHP_URL_SCHEME) |
|
602 | + ); |
|
603 | + } |
|
604 | + |
|
605 | + /** |
|
606 | + * Creates a temporary file with support for custom stream wrappers. |
|
607 | + * |
|
608 | + * @param string $prefix The prefix of the generated temporary filename |
|
609 | + * Note: Windows uses only the first three characters of prefix |
|
610 | + * @param string $suffix The suffix of the generated temporary filename |
|
611 | + * |
|
612 | + * @return string The new temporary filename (with path), or throw an exception on failure |
|
613 | + */ |
|
614 | + public function tempnam(string $dir, string $prefix/*, string $suffix = ''*/) |
|
615 | + { |
|
616 | + $suffix = \func_num_args() > 2 ? func_get_arg(2) : ''; |
|
617 | + [$scheme, $hierarchy] = $this->getSchemeAndHierarchy($dir); |
|
618 | + |
|
619 | + // If no scheme or scheme is "file" or "gs" (Google Cloud) create temp file in local filesystem |
|
620 | + if ((null === $scheme || 'file' === $scheme || 'gs' === $scheme) && '' === $suffix) { |
|
621 | + // If tempnam failed or no scheme return the filename otherwise prepend the scheme |
|
622 | + if ($tmpFile = self::box('tempnam', $hierarchy, $prefix)) { |
|
623 | + if (null !== $scheme && 'gs' !== $scheme) { |
|
624 | + return $scheme.'://'.$tmpFile; |
|
625 | + } |
|
626 | + |
|
627 | + return $tmpFile; |
|
628 | + } |
|
629 | + |
|
630 | + throw new IOException('A temporary file could not be created: '.self::$lastError); |
|
631 | + } |
|
632 | + |
|
633 | + // Loop until we create a valid temp file or have reached 10 attempts |
|
634 | + for ($i = 0; $i < 10; ++$i) { |
|
635 | + // Create a unique filename |
|
636 | + $tmpFile = $dir.'/'.$prefix.uniqid(mt_rand(), true).$suffix; |
|
637 | + |
|
638 | + // Use fopen instead of file_exists as some streams do not support stat |
|
639 | + // Use mode 'x+' to atomically check existence and create to avoid a TOCTOU vulnerability |
|
640 | + if (!$handle = self::box('fopen', $tmpFile, 'x+')) { |
|
641 | + continue; |
|
642 | + } |
|
643 | + |
|
644 | + // Close the file if it was successfully opened |
|
645 | + self::box('fclose', $handle); |
|
646 | + |
|
647 | + return $tmpFile; |
|
648 | + } |
|
649 | + |
|
650 | + throw new IOException('A temporary file could not be created: '.self::$lastError); |
|
651 | + } |
|
652 | + |
|
653 | + /** |
|
654 | + * Atomically dumps content into a file. |
|
655 | + * |
|
656 | + * @param string|resource $content The data to write into the file |
|
657 | + * |
|
658 | + * @throws IOException if the file cannot be written to |
|
659 | + */ |
|
660 | + public function dumpFile(string $filename, $content) |
|
661 | + { |
|
662 | + if (\is_array($content)) { |
|
663 | + throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|
664 | + } |
|
665 | + |
|
666 | + $dir = \dirname($filename); |
|
667 | + |
|
668 | + if (!is_dir($dir)) { |
|
669 | + $this->mkdir($dir); |
|
670 | + } |
|
671 | + |
|
672 | + // Will create a temp file with 0600 access rights |
|
673 | + // when the filesystem supports chmod. |
|
674 | + $tmpFile = $this->tempnam($dir, basename($filename)); |
|
675 | + |
|
676 | + try { |
|
677 | + if (false === self::box('file_put_contents', $tmpFile, $content)) { |
|
678 | + throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|
679 | + } |
|
680 | + |
|
681 | + self::box('chmod', $tmpFile, file_exists($filename) ? fileperms($filename) : 0666 & ~umask()); |
|
682 | + |
|
683 | + $this->rename($tmpFile, $filename, true); |
|
684 | + } finally { |
|
685 | + if (file_exists($tmpFile)) { |
|
686 | + self::box('unlink', $tmpFile); |
|
687 | + } |
|
688 | + } |
|
689 | + } |
|
690 | + |
|
691 | + /** |
|
692 | + * Appends content to an existing file. |
|
693 | + * |
|
694 | + * @param string|resource $content The content to append |
|
695 | + * |
|
696 | + * @throws IOException If the file is not writable |
|
697 | + */ |
|
698 | + public function appendToFile(string $filename, $content) |
|
699 | + { |
|
700 | + if (\is_array($content)) { |
|
701 | + throw new \TypeError(sprintf('Argument 2 passed to "%s()" must be string or resource, array given.', __METHOD__)); |
|
702 | + } |
|
703 | + |
|
704 | + $dir = \dirname($filename); |
|
705 | + |
|
706 | + if (!is_dir($dir)) { |
|
707 | + $this->mkdir($dir); |
|
708 | + } |
|
709 | + |
|
710 | + if (false === self::box('file_put_contents', $filename, $content, \FILE_APPEND)) { |
|
711 | + throw new IOException(sprintf('Failed to write file "%s": ', $filename).self::$lastError, 0, null, $filename); |
|
712 | + } |
|
713 | + } |
|
714 | + |
|
715 | + private function toIterable($files): iterable |
|
716 | + { |
|
717 | + return is_iterable($files) ? $files : [$files]; |
|
718 | + } |
|
719 | + |
|
720 | + /** |
|
721 | + * Gets a 2-tuple of scheme (may be null) and hierarchical part of a filename (e.g. file:///tmp -> [file, tmp]). |
|
722 | + */ |
|
723 | + private function getSchemeAndHierarchy(string $filename): array |
|
724 | + { |
|
725 | + $components = explode('://', $filename, 2); |
|
726 | + |
|
727 | + return 2 === \count($components) ? [$components[0], $components[1]] : [null, $components[0]]; |
|
728 | + } |
|
729 | + |
|
730 | + /** |
|
731 | + * @param mixed ...$args |
|
732 | + * |
|
733 | + * @return mixed |
|
734 | + */ |
|
735 | + private static function box(callable $func, ...$args) |
|
736 | + { |
|
737 | + self::$lastError = null; |
|
738 | + set_error_handler(__CLASS__.'::handleError'); |
|
739 | + try { |
|
740 | + $result = $func(...$args); |
|
741 | + restore_error_handler(); |
|
742 | + |
|
743 | + return $result; |
|
744 | + } catch (\Throwable $e) { |
|
745 | + } |
|
746 | + restore_error_handler(); |
|
747 | + |
|
748 | + throw $e; |
|
749 | + } |
|
750 | + |
|
751 | + /** |
|
752 | + * @internal |
|
753 | + */ |
|
754 | + public static function handleError(int $type, string $msg) |
|
755 | + { |
|
756 | + self::$lastError = $msg; |
|
757 | + } |
|
758 | 758 | } |