class-windowspath.php
126 lines
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | |
| 4 | namespace WordPress\Filesystem\Path; |
| 5 | |
| 6 | final class WindowsPath { |
| 7 | |
| 8 | private const SEP = '\\'; |
| 9 | |
| 10 | /** Converts every “/” to “\” so that later code can assume one separator. */ |
| 11 | private static function normalize_separators( string $path ): string { |
| 12 | return str_replace( '/', self::SEP, $path ); |
| 13 | } |
| 14 | |
| 15 | /** Absolute = “C:\…\” or “\\server\share\…”. */ |
| 16 | private static function is_absolute( string $path ): bool { |
| 17 | $path = self::normalize_separators( $path ); |
| 18 | return (bool) preg_match( '/^(?:[A-Za-z]:\\\\|\\\\\\\\[^\\\\]+\\\\[^\\\\]+)/', $path ); |
| 19 | } |
| 20 | |
| 21 | /** Returns “C:\foo\bar” → ["C:", "foo", "bar"]; “\\srv\share\dir” → ["srv", "share", "dir"]. */ |
| 22 | public static function path_segments( string $path ): array { |
| 23 | $canonical = self::canonicalize( $path ); |
| 24 | $trimmed = trim( $canonical, self::SEP ); |
| 25 | |
| 26 | // root “C:\” or “\\srv\share\” gives empty $trimmed, keep the original string. |
| 27 | if ( '' === $trimmed ) { |
| 28 | return array( $canonical ); |
| 29 | } |
| 30 | return array_values( array_filter( explode( self::SEP, $trimmed ), 'strlen' ) ); |
| 31 | } |
| 32 | |
| 33 | /** Joins segments with a single backslash and keeps any drive-letter or UNC prefix intact. */ |
| 34 | public static function join_paths( string ...$segments ): string { |
| 35 | if ( ! $segments ) { |
| 36 | return ''; |
| 37 | } |
| 38 | |
| 39 | $pieces = array(); |
| 40 | $first = null; |
| 41 | foreach ( $segments as $seg ) { |
| 42 | if ( '' === $seg ) { |
| 43 | continue; |
| 44 | } |
| 45 | if ( null === $first ) { |
| 46 | $first = $seg; |
| 47 | } |
| 48 | $pieces[] = trim( self::normalize_separators( $seg ), '\\/' ); |
| 49 | } |
| 50 | $joined = implode( self::SEP, $pieces ); |
| 51 | |
| 52 | // restore UNC double backslash if needed. |
| 53 | if ( preg_match( '/^\\\\\\\\/', self::normalize_separators( $first ) ) ) { |
| 54 | return '\\\\' . ltrim( $joined, self::SEP ); |
| 55 | } |
| 56 | return $joined; |
| 57 | } |
| 58 | |
| 59 | /** Mirrors Node.js path.resolve for Windows rules. */ |
| 60 | public static function resolve_path( string ...$segments ): string { |
| 61 | for ( $i = count( $segments ) - 1; $i >= 0; --$i ) { |
| 62 | if ( self::is_absolute( $segments[ $i ] ) ) { |
| 63 | return self::canonicalize( |
| 64 | self::join_paths( ...array_slice( $segments, $i ) ) |
| 65 | ); |
| 66 | } |
| 67 | } |
| 68 | array_unshift( $segments, getcwd() ); |
| 69 | return self::canonicalize( self::join_paths( ...$segments ) ); |
| 70 | } |
| 71 | |
| 72 | /** |
| 73 | * Cleans up a path, guarantees it is absolute and free of “\.” / “\..”. |
| 74 | * Keeps trailing backslash only for drive-root (“C:\”) or UNC-root (“\\srv\share\”). |
| 75 | */ |
| 76 | public static function canonicalize( string $path ): string { |
| 77 | $path = self::normalize_separators( trim( $path ) ); |
| 78 | |
| 79 | if ( ! self::is_absolute( $path ) ) { |
| 80 | $cwd = rtrim( getcwd(), self::SEP ); |
| 81 | $path = $cwd . self::SEP . $path; |
| 82 | } |
| 83 | |
| 84 | // split prefix (drive or UNC) from the rest. |
| 85 | preg_match( '/^(\\\\\\\\[^\\\\]+\\\\[^\\\\]+|[A-Za-z]:)(?:\\\\)?(.*)$/', $path, $m ); |
| 86 | $prefix = $m[1] ?? ''; |
| 87 | $rest = $m[2] ?? ''; |
| 88 | |
| 89 | $stack = array(); |
| 90 | foreach ( explode( self::SEP, $rest ) as $part ) { |
| 91 | if ( '' === $part || '.' === $part ) { |
| 92 | continue; |
| 93 | } |
| 94 | if ( '..' === $part ) { |
| 95 | array_pop( $stack ); |
| 96 | continue; |
| 97 | } |
| 98 | $stack[] = $part; |
| 99 | } |
| 100 | |
| 101 | $resolved = $prefix; |
| 102 | if ( '' !== $resolved && self::SEP !== substr( $resolved, -1 ) ) { |
| 103 | $resolved .= self::SEP; |
| 104 | } |
| 105 | $resolved .= implode( self::SEP, $stack ); |
| 106 | |
| 107 | // Drive-root and UNC-root must end with “\”. |
| 108 | if ( $resolved === $prefix ) { |
| 109 | $resolved .= self::SEP; |
| 110 | } |
| 111 | return $resolved; |
| 112 | } |
| 113 | |
| 114 | /** Consistent dirname() that sticks to backslashes. */ |
| 115 | public static function dirname( string $path ): string { |
| 116 | $path = self::normalize_separators( $path ); |
| 117 | $dir = dirname( $path ); |
| 118 | |
| 119 | // dirname("C:\foo") returns "C:\", keep that trailing sep; but dirname("C:\") yields "C:\". |
| 120 | if ( preg_match( '/^[A-Za-z]:$/', $dir ) ) { |
| 121 | $dir .= self::SEP; |
| 122 | } |
| 123 | return $dir; |
| 124 | } |
| 125 | } |
| 126 |