Path.php 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Filesystem;
  11. use Symfony\Component\Filesystem\Exception\InvalidArgumentException;
  12. use Symfony\Component\Filesystem\Exception\RuntimeException;
  13. /**
  14. * Contains utility methods for handling path strings.
  15. *
  16. * The methods in this class are able to deal with both UNIX and Windows paths
  17. * with both forward and backward slashes. All methods return normalized parts
  18. * containing only forward slashes and no excess "." and ".." segments.
  19. *
  20. * @author Bernhard Schussek <bschussek@gmail.com>
  21. * @author Thomas Schulz <mail@king2500.net>
  22. * @author Théo Fidry <theo.fidry@gmail.com>
  23. */
  24. final class Path
  25. {
  26. /**
  27. * The number of buffer entries that triggers a cleanup operation.
  28. */
  29. private const CLEANUP_THRESHOLD = 1250;
  30. /**
  31. * The buffer size after the cleanup operation.
  32. */
  33. private const CLEANUP_SIZE = 1000;
  34. /**
  35. * Buffers input/output of {@link canonicalize()}.
  36. *
  37. * @var array<string, string>
  38. */
  39. private static array $buffer = [];
  40. private static int $bufferSize = 0;
  41. /**
  42. * Canonicalizes the given path.
  43. *
  44. * During normalization, all slashes are replaced by forward slashes ("/").
  45. * Furthermore, all "." and ".." segments are removed as far as possible.
  46. * ".." segments at the beginning of relative paths are not removed.
  47. *
  48. * ```php
  49. * echo Path::canonicalize("\symfony\puli\..\css\style.css");
  50. * // => /symfony/css/style.css
  51. *
  52. * echo Path::canonicalize("../css/./style.css");
  53. * // => ../css/style.css
  54. * ```
  55. *
  56. * This method is able to deal with both UNIX and Windows paths.
  57. */
  58. public static function canonicalize(string $path): string
  59. {
  60. if ('' === $path) {
  61. return '';
  62. }
  63. // This method is called by many other methods in this class. Buffer
  64. // the canonicalized paths to make up for the severe performance
  65. // decrease.
  66. if (isset(self::$buffer[$path])) {
  67. return self::$buffer[$path];
  68. }
  69. // Replace "~" with user's home directory.
  70. if ('~' === $path[0]) {
  71. $path = self::getHomeDirectory().substr($path, 1);
  72. }
  73. $path = self::normalize($path);
  74. [$root, $pathWithoutRoot] = self::split($path);
  75. $canonicalParts = self::findCanonicalParts($root, $pathWithoutRoot);
  76. // Add the root directory again
  77. self::$buffer[$path] = $canonicalPath = $root.implode('/', $canonicalParts);
  78. ++self::$bufferSize;
  79. // Clean up regularly to prevent memory leaks
  80. if (self::$bufferSize > self::CLEANUP_THRESHOLD) {
  81. self::$buffer = \array_slice(self::$buffer, -self::CLEANUP_SIZE, null, true);
  82. self::$bufferSize = self::CLEANUP_SIZE;
  83. }
  84. return $canonicalPath;
  85. }
  86. /**
  87. * Normalizes the given path.
  88. *
  89. * During normalization, all slashes are replaced by forward slashes ("/").
  90. * Contrary to {@link canonicalize()}, this method does not remove invalid
  91. * or dot path segments. Consequently, it is much more efficient and should
  92. * be used whenever the given path is known to be a valid, absolute system
  93. * path.
  94. *
  95. * This method is able to deal with both UNIX and Windows paths.
  96. */
  97. public static function normalize(string $path): string
  98. {
  99. return str_replace('\\', '/', $path);
  100. }
  101. /**
  102. * Returns the directory part of the path.
  103. *
  104. * This method is similar to PHP's dirname(), but handles various cases
  105. * where dirname() returns a weird result:
  106. *
  107. * - dirname() does not accept backslashes on UNIX
  108. * - dirname("C:/symfony") returns "C:", not "C:/"
  109. * - dirname("C:/") returns ".", not "C:/"
  110. * - dirname("C:") returns ".", not "C:/"
  111. * - dirname("symfony") returns ".", not ""
  112. * - dirname() does not canonicalize the result
  113. *
  114. * This method fixes these shortcomings and behaves like dirname()
  115. * otherwise.
  116. *
  117. * The result is a canonical path.
  118. *
  119. * @return string The canonical directory part. Returns the root directory
  120. * if the root directory is passed. Returns an empty string
  121. * if a relative path is passed that contains no slashes.
  122. * Returns an empty string if an empty string is passed.
  123. */
  124. public static function getDirectory(string $path): string
  125. {
  126. if ('' === $path) {
  127. return '';
  128. }
  129. $path = self::canonicalize($path);
  130. // Maintain scheme
  131. if (false !== $schemeSeparatorPosition = strpos($path, '://')) {
  132. $scheme = substr($path, 0, $schemeSeparatorPosition + 3);
  133. $path = substr($path, $schemeSeparatorPosition + 3);
  134. } else {
  135. $scheme = '';
  136. }
  137. if (false === $dirSeparatorPosition = strrpos($path, '/')) {
  138. return '';
  139. }
  140. // Directory equals root directory "/"
  141. if (0 === $dirSeparatorPosition) {
  142. return $scheme.'/';
  143. }
  144. // Directory equals Windows root "C:/"
  145. if (2 === $dirSeparatorPosition && ctype_alpha($path[0]) && ':' === $path[1]) {
  146. return $scheme.substr($path, 0, 3);
  147. }
  148. return $scheme.substr($path, 0, $dirSeparatorPosition);
  149. }
  150. /**
  151. * Returns canonical path of the user's home directory.
  152. *
  153. * Supported operating systems:
  154. *
  155. * - UNIX
  156. * - Windows8 and upper
  157. *
  158. * If your operating system or environment isn't supported, an exception is thrown.
  159. *
  160. * The result is a canonical path.
  161. *
  162. * @throws RuntimeException If your operating system or environment isn't supported
  163. */
  164. public static function getHomeDirectory(): string
  165. {
  166. // For UNIX support
  167. if (getenv('HOME')) {
  168. return self::canonicalize(getenv('HOME'));
  169. }
  170. // For >= Windows8 support
  171. if (getenv('HOMEDRIVE') && getenv('HOMEPATH')) {
  172. return self::canonicalize(getenv('HOMEDRIVE').getenv('HOMEPATH'));
  173. }
  174. throw new RuntimeException("Cannot find the home directory path: Your environment or operating system isn't supported.");
  175. }
  176. /**
  177. * Returns the root directory of a path.
  178. *
  179. * The result is a canonical path.
  180. *
  181. * @return string The canonical root directory. Returns an empty string if
  182. * the given path is relative or empty.
  183. */
  184. public static function getRoot(string $path): string
  185. {
  186. if ('' === $path) {
  187. return '';
  188. }
  189. // Maintain scheme
  190. if (false !== $schemeSeparatorPosition = strpos($path, '://')) {
  191. $scheme = substr($path, 0, $schemeSeparatorPosition + 3);
  192. $path = substr($path, $schemeSeparatorPosition + 3);
  193. } else {
  194. $scheme = '';
  195. }
  196. $firstCharacter = $path[0];
  197. // UNIX root "/" or "\" (Windows style)
  198. if ('/' === $firstCharacter || '\\' === $firstCharacter) {
  199. return $scheme.'/';
  200. }
  201. $length = \strlen($path);
  202. // Windows root
  203. if ($length > 1 && ':' === $path[1] && ctype_alpha($firstCharacter)) {
  204. // Special case: "C:"
  205. if (2 === $length) {
  206. return $scheme.$path.'/';
  207. }
  208. // Normal case: "C:/ or "C:\"
  209. if ('/' === $path[2] || '\\' === $path[2]) {
  210. return $scheme.$firstCharacter.$path[1].'/';
  211. }
  212. }
  213. return '';
  214. }
  215. /**
  216. * Returns the file name without the extension from a file path.
  217. *
  218. * @param string|null $extension if specified, only that extension is cut
  219. * off (may contain leading dot)
  220. */
  221. public static function getFilenameWithoutExtension(string $path, ?string $extension = null): string
  222. {
  223. if ('' === $path) {
  224. return '';
  225. }
  226. if (null !== $extension) {
  227. // remove extension and trailing dot
  228. return rtrim(basename($path, $extension), '.');
  229. }
  230. return pathinfo($path, \PATHINFO_FILENAME);
  231. }
  232. /**
  233. * Returns the extension from a file path (without leading dot).
  234. *
  235. * @param bool $forceLowerCase forces the extension to be lower-case
  236. */
  237. public static function getExtension(string $path, bool $forceLowerCase = false): string
  238. {
  239. if ('' === $path) {
  240. return '';
  241. }
  242. $extension = pathinfo($path, \PATHINFO_EXTENSION);
  243. if ($forceLowerCase) {
  244. $extension = self::toLower($extension);
  245. }
  246. return $extension;
  247. }
  248. /**
  249. * Returns whether the path has an (or the specified) extension.
  250. *
  251. * @param string $path the path string
  252. * @param string|string[]|null $extensions if null or not provided, checks if
  253. * an extension exists, otherwise
  254. * checks for the specified extension
  255. * or array of extensions (with or
  256. * without leading dot)
  257. * @param bool $ignoreCase whether to ignore case-sensitivity
  258. */
  259. public static function hasExtension(string $path, $extensions = null, bool $ignoreCase = false): bool
  260. {
  261. if ('' === $path) {
  262. return false;
  263. }
  264. $actualExtension = self::getExtension($path, $ignoreCase);
  265. // Only check if path has any extension
  266. if ([] === $extensions || null === $extensions) {
  267. return '' !== $actualExtension;
  268. }
  269. if (\is_string($extensions)) {
  270. $extensions = [$extensions];
  271. }
  272. foreach ($extensions as $key => $extension) {
  273. if ($ignoreCase) {
  274. $extension = self::toLower($extension);
  275. }
  276. // remove leading '.' in extensions array
  277. $extensions[$key] = ltrim($extension, '.');
  278. }
  279. return \in_array($actualExtension, $extensions, true);
  280. }
  281. /**
  282. * Changes the extension of a path string.
  283. *
  284. * @param string $path The path string with filename.ext to change.
  285. * @param string $extension new extension (with or without leading dot)
  286. *
  287. * @return string the path string with new file extension
  288. */
  289. public static function changeExtension(string $path, string $extension): string
  290. {
  291. if ('' === $path) {
  292. return '';
  293. }
  294. $actualExtension = self::getExtension($path);
  295. $extension = ltrim($extension, '.');
  296. // No extension for paths
  297. if ('/' === substr($path, -1)) {
  298. return $path;
  299. }
  300. // No actual extension in path
  301. if (empty($actualExtension)) {
  302. return $path.('.' === substr($path, -1) ? '' : '.').$extension;
  303. }
  304. return substr($path, 0, -\strlen($actualExtension)).$extension;
  305. }
  306. public static function isAbsolute(string $path): bool
  307. {
  308. if ('' === $path) {
  309. return false;
  310. }
  311. // Strip scheme
  312. if (false !== ($schemeSeparatorPosition = strpos($path, '://')) && 1 !== $schemeSeparatorPosition) {
  313. $path = substr($path, $schemeSeparatorPosition + 3);
  314. }
  315. $firstCharacter = $path[0];
  316. // UNIX root "/" or "\" (Windows style)
  317. if ('/' === $firstCharacter || '\\' === $firstCharacter) {
  318. return true;
  319. }
  320. // Windows root
  321. if (\strlen($path) > 1 && ctype_alpha($firstCharacter) && ':' === $path[1]) {
  322. // Special case: "C:"
  323. if (2 === \strlen($path)) {
  324. return true;
  325. }
  326. // Normal case: "C:/ or "C:\"
  327. if ('/' === $path[2] || '\\' === $path[2]) {
  328. return true;
  329. }
  330. }
  331. return false;
  332. }
  333. public static function isRelative(string $path): bool
  334. {
  335. return !self::isAbsolute($path);
  336. }
  337. /**
  338. * Turns a relative path into an absolute path in canonical form.
  339. *
  340. * Usually, the relative path is appended to the given base path. Dot
  341. * segments ("." and "..") are removed/collapsed and all slashes turned
  342. * into forward slashes.
  343. *
  344. * ```php
  345. * echo Path::makeAbsolute("../style.css", "/symfony/puli/css");
  346. * // => /symfony/puli/style.css
  347. * ```
  348. *
  349. * If an absolute path is passed, that path is returned unless its root
  350. * directory is different than the one of the base path. In that case, an
  351. * exception is thrown.
  352. *
  353. * ```php
  354. * Path::makeAbsolute("/style.css", "/symfony/puli/css");
  355. * // => /style.css
  356. *
  357. * Path::makeAbsolute("C:/style.css", "C:/symfony/puli/css");
  358. * // => C:/style.css
  359. *
  360. * Path::makeAbsolute("C:/style.css", "/symfony/puli/css");
  361. * // InvalidArgumentException
  362. * ```
  363. *
  364. * If the base path is not an absolute path, an exception is thrown.
  365. *
  366. * The result is a canonical path.
  367. *
  368. * @param string $basePath an absolute base path
  369. *
  370. * @throws InvalidArgumentException if the base path is not absolute or if
  371. * the given path is an absolute path with
  372. * a different root than the base path
  373. */
  374. public static function makeAbsolute(string $path, string $basePath): string
  375. {
  376. if ('' === $basePath) {
  377. throw new InvalidArgumentException(sprintf('The base path must be a non-empty string. Got: "%s".', $basePath));
  378. }
  379. if (!self::isAbsolute($basePath)) {
  380. throw new InvalidArgumentException(sprintf('The base path "%s" is not an absolute path.', $basePath));
  381. }
  382. if (self::isAbsolute($path)) {
  383. return self::canonicalize($path);
  384. }
  385. if (false !== $schemeSeparatorPosition = strpos($basePath, '://')) {
  386. $scheme = substr($basePath, 0, $schemeSeparatorPosition + 3);
  387. $basePath = substr($basePath, $schemeSeparatorPosition + 3);
  388. } else {
  389. $scheme = '';
  390. }
  391. return $scheme.self::canonicalize(rtrim($basePath, '/\\').'/'.$path);
  392. }
  393. /**
  394. * Turns a path into a relative path.
  395. *
  396. * The relative path is created relative to the given base path:
  397. *
  398. * ```php
  399. * echo Path::makeRelative("/symfony/style.css", "/symfony/puli");
  400. * // => ../style.css
  401. * ```
  402. *
  403. * If a relative path is passed and the base path is absolute, the relative
  404. * path is returned unchanged:
  405. *
  406. * ```php
  407. * Path::makeRelative("style.css", "/symfony/puli/css");
  408. * // => style.css
  409. * ```
  410. *
  411. * If both paths are relative, the relative path is created with the
  412. * assumption that both paths are relative to the same directory:
  413. *
  414. * ```php
  415. * Path::makeRelative("style.css", "symfony/puli/css");
  416. * // => ../../../style.css
  417. * ```
  418. *
  419. * If both paths are absolute, their root directory must be the same,
  420. * otherwise an exception is thrown:
  421. *
  422. * ```php
  423. * Path::makeRelative("C:/symfony/style.css", "/symfony/puli");
  424. * // InvalidArgumentException
  425. * ```
  426. *
  427. * If the passed path is absolute, but the base path is not, an exception
  428. * is thrown as well:
  429. *
  430. * ```php
  431. * Path::makeRelative("/symfony/style.css", "symfony/puli");
  432. * // InvalidArgumentException
  433. * ```
  434. *
  435. * If the base path is not an absolute path, an exception is thrown.
  436. *
  437. * The result is a canonical path.
  438. *
  439. * @throws InvalidArgumentException if the base path is not absolute or if
  440. * the given path has a different root
  441. * than the base path
  442. */
  443. public static function makeRelative(string $path, string $basePath): string
  444. {
  445. $path = self::canonicalize($path);
  446. $basePath = self::canonicalize($basePath);
  447. [$root, $relativePath] = self::split($path);
  448. [$baseRoot, $relativeBasePath] = self::split($basePath);
  449. // If the base path is given as absolute path and the path is already
  450. // relative, consider it to be relative to the given absolute path
  451. // already
  452. if ('' === $root && '' !== $baseRoot) {
  453. // If base path is already in its root
  454. if ('' === $relativeBasePath) {
  455. $relativePath = ltrim($relativePath, './\\');
  456. }
  457. return $relativePath;
  458. }
  459. // If the passed path is absolute, but the base path is not, we
  460. // cannot generate a relative path
  461. if ('' !== $root && '' === $baseRoot) {
  462. throw new InvalidArgumentException(sprintf('The absolute path "%s" cannot be made relative to the relative path "%s". You should provide an absolute base path instead.', $path, $basePath));
  463. }
  464. // Fail if the roots of the two paths are different
  465. if ($baseRoot && $root !== $baseRoot) {
  466. throw new InvalidArgumentException(sprintf('The path "%s" cannot be made relative to "%s", because they have different roots ("%s" and "%s").', $path, $basePath, $root, $baseRoot));
  467. }
  468. if ('' === $relativeBasePath) {
  469. return $relativePath;
  470. }
  471. // Build a "../../" prefix with as many "../" parts as necessary
  472. $parts = explode('/', $relativePath);
  473. $baseParts = explode('/', $relativeBasePath);
  474. $dotDotPrefix = '';
  475. // Once we found a non-matching part in the prefix, we need to add
  476. // "../" parts for all remaining parts
  477. $match = true;
  478. foreach ($baseParts as $index => $basePart) {
  479. if ($match && isset($parts[$index]) && $basePart === $parts[$index]) {
  480. unset($parts[$index]);
  481. continue;
  482. }
  483. $match = false;
  484. $dotDotPrefix .= '../';
  485. }
  486. return rtrim($dotDotPrefix.implode('/', $parts), '/');
  487. }
  488. /**
  489. * Returns whether the given path is on the local filesystem.
  490. */
  491. public static function isLocal(string $path): bool
  492. {
  493. return '' !== $path && !str_contains($path, '://');
  494. }
  495. /**
  496. * Returns the longest common base path in canonical form of a set of paths or
  497. * `null` if the paths are on different Windows partitions.
  498. *
  499. * Dot segments ("." and "..") are removed/collapsed and all slashes turned
  500. * into forward slashes.
  501. *
  502. * ```php
  503. * $basePath = Path::getLongestCommonBasePath(
  504. * '/symfony/css/style.css',
  505. * '/symfony/css/..'
  506. * );
  507. * // => /symfony
  508. * ```
  509. *
  510. * The root is returned if no common base path can be found:
  511. *
  512. * ```php
  513. * $basePath = Path::getLongestCommonBasePath(
  514. * '/symfony/css/style.css',
  515. * '/puli/css/..'
  516. * );
  517. * // => /
  518. * ```
  519. *
  520. * If the paths are located on different Windows partitions, `null` is
  521. * returned.
  522. *
  523. * ```php
  524. * $basePath = Path::getLongestCommonBasePath(
  525. * 'C:/symfony/css/style.css',
  526. * 'D:/symfony/css/..'
  527. * );
  528. * // => null
  529. * ```
  530. */
  531. public static function getLongestCommonBasePath(string ...$paths): ?string
  532. {
  533. [$bpRoot, $basePath] = self::split(self::canonicalize(reset($paths)));
  534. for (next($paths); null !== key($paths) && '' !== $basePath; next($paths)) {
  535. [$root, $path] = self::split(self::canonicalize(current($paths)));
  536. // If we deal with different roots (e.g. C:/ vs. D:/), it's time
  537. // to quit
  538. if ($root !== $bpRoot) {
  539. return null;
  540. }
  541. // Make the base path shorter until it fits into path
  542. while (true) {
  543. if ('.' === $basePath) {
  544. // No more base paths
  545. $basePath = '';
  546. // next path
  547. continue 2;
  548. }
  549. // Prevent false positives for common prefixes
  550. // see isBasePath()
  551. if (str_starts_with($path.'/', $basePath.'/')) {
  552. // next path
  553. continue 2;
  554. }
  555. $basePath = \dirname($basePath);
  556. }
  557. }
  558. return $bpRoot.$basePath;
  559. }
  560. /**
  561. * Joins two or more path strings into a canonical path.
  562. */
  563. public static function join(string ...$paths): string
  564. {
  565. $finalPath = null;
  566. $wasScheme = false;
  567. foreach ($paths as $path) {
  568. if ('' === $path) {
  569. continue;
  570. }
  571. if (null === $finalPath) {
  572. // For first part we keep slashes, like '/top', 'C:\' or 'phar://'
  573. $finalPath = $path;
  574. $wasScheme = str_contains($path, '://');
  575. continue;
  576. }
  577. // Only add slash if previous part didn't end with '/' or '\'
  578. if (!\in_array(substr($finalPath, -1), ['/', '\\'])) {
  579. $finalPath .= '/';
  580. }
  581. // If first part included a scheme like 'phar://' we allow \current part to start with '/', otherwise trim
  582. $finalPath .= $wasScheme ? $path : ltrim($path, '/');
  583. $wasScheme = false;
  584. }
  585. if (null === $finalPath) {
  586. return '';
  587. }
  588. return self::canonicalize($finalPath);
  589. }
  590. /**
  591. * Returns whether a path is a base path of another path.
  592. *
  593. * Dot segments ("." and "..") are removed/collapsed and all slashes turned
  594. * into forward slashes.
  595. *
  596. * ```php
  597. * Path::isBasePath('/symfony', '/symfony/css');
  598. * // => true
  599. *
  600. * Path::isBasePath('/symfony', '/symfony');
  601. * // => true
  602. *
  603. * Path::isBasePath('/symfony', '/symfony/..');
  604. * // => false
  605. *
  606. * Path::isBasePath('/symfony', '/puli');
  607. * // => false
  608. * ```
  609. */
  610. public static function isBasePath(string $basePath, string $ofPath): bool
  611. {
  612. $basePath = self::canonicalize($basePath);
  613. $ofPath = self::canonicalize($ofPath);
  614. // Append slashes to prevent false positives when two paths have
  615. // a common prefix, for example /base/foo and /base/foobar.
  616. // Don't append a slash for the root "/", because then that root
  617. // won't be discovered as common prefix ("//" is not a prefix of
  618. // "/foobar/").
  619. return str_starts_with($ofPath.'/', rtrim($basePath, '/').'/');
  620. }
  621. /**
  622. * @return string[]
  623. */
  624. private static function findCanonicalParts(string $root, string $pathWithoutRoot): array
  625. {
  626. $parts = explode('/', $pathWithoutRoot);
  627. $canonicalParts = [];
  628. // Collapse "." and "..", if possible
  629. foreach ($parts as $part) {
  630. if ('.' === $part || '' === $part) {
  631. continue;
  632. }
  633. // Collapse ".." with the previous part, if one exists
  634. // Don't collapse ".." if the previous part is also ".."
  635. if ('..' === $part && \count($canonicalParts) > 0 && '..' !== $canonicalParts[\count($canonicalParts) - 1]) {
  636. array_pop($canonicalParts);
  637. continue;
  638. }
  639. // Only add ".." prefixes for relative paths
  640. if ('..' !== $part || '' === $root) {
  641. $canonicalParts[] = $part;
  642. }
  643. }
  644. return $canonicalParts;
  645. }
  646. /**
  647. * Splits a canonical path into its root directory and the remainder.
  648. *
  649. * If the path has no root directory, an empty root directory will be
  650. * returned.
  651. *
  652. * If the root directory is a Windows style partition, the resulting root
  653. * will always contain a trailing slash.
  654. *
  655. * list ($root, $path) = Path::split("C:/symfony")
  656. * // => ["C:/", "symfony"]
  657. *
  658. * list ($root, $path) = Path::split("C:")
  659. * // => ["C:/", ""]
  660. *
  661. * @return array{string, string} an array with the root directory and the remaining relative path
  662. */
  663. private static function split(string $path): array
  664. {
  665. if ('' === $path) {
  666. return ['', ''];
  667. }
  668. // Remember scheme as part of the root, if any
  669. if (false !== $schemeSeparatorPosition = strpos($path, '://')) {
  670. $root = substr($path, 0, $schemeSeparatorPosition + 3);
  671. $path = substr($path, $schemeSeparatorPosition + 3);
  672. } else {
  673. $root = '';
  674. }
  675. $length = \strlen($path);
  676. // Remove and remember root directory
  677. if (str_starts_with($path, '/')) {
  678. $root .= '/';
  679. $path = $length > 1 ? substr($path, 1) : '';
  680. } elseif ($length > 1 && ctype_alpha($path[0]) && ':' === $path[1]) {
  681. if (2 === $length) {
  682. // Windows special case: "C:"
  683. $root .= $path.'/';
  684. $path = '';
  685. } elseif ('/' === $path[2]) {
  686. // Windows normal case: "C:/"..
  687. $root .= substr($path, 0, 3);
  688. $path = $length > 3 ? substr($path, 3) : '';
  689. }
  690. }
  691. return [$root, $path];
  692. }
  693. private static function toLower(string $string): string
  694. {
  695. if (false !== $encoding = mb_detect_encoding($string, null, true)) {
  696. return mb_strtolower($string, $encoding);
  697. }
  698. return strtolower($string);
  699. }
  700. private function __construct()
  701. {
  702. }
  703. }