Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
RelPath
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
3 / 3
27
100.00% covered (success)
100.00%
1 / 1
 splitPath
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
11
 getRelativePath
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 joinPath
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2/**
3 * Copyright (c) 2015 Ori Livneh <ori@wikimedia.org>
4 *
5 * Permission is hereby granted, free of charge, to any person obtaining
6 * a copy of this software and associated documentation files (the
7 * "Software"), to deal in the Software without restriction, including
8 * without limitation the rights to use, copy, modify, merge, publish,
9 * distribute, sublicense, and/or sell copies of the Software, and to
10 * permit persons to whom the Software is furnished to do so, subject to
11 * the following conditions:
12 *
13 * The above copyright notice and this permission notice shall be
14 * included in all copies or substantial portions of the Software.
15 *
16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
20 * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
21 * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
22 * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
23 *
24 * @file
25 * @author Ori Livneh <ori@wikimedia.org>
26 */
27
28namespace Wikimedia;
29
30/**
31 * Utilities for computing a relative filepath between two paths.
32 */
33class RelPath {
34    /**
35     * Split a path into path components.
36     *
37     * @param string $path File path.
38     * @return string[] Array of path components.
39     */
40    public static function splitPath( string $path ): array {
41        $fragments = [];
42
43        while ( true ) {
44            $cur = dirname( $path );
45            if ( $cur[0] === DIRECTORY_SEPARATOR ) {
46                // dirname() on Windows sometimes returns a leading backslash, but other
47                // times retains the leading forward slash. Slashes other than the leading one
48                // are returned as-is, and therefore do not need to be touched.
49                // Furthermore, don't break on *nix where \ is allowed in file/directory names.
50                $cur[0] = '/';
51            }
52
53            if ( $cur === $path || ( $cur === '.' && basename( $path ) === $path ) ) {
54                break;
55            }
56
57            $fragment = trim( substr( $path, strlen( $cur ) ), '/' );
58
59            if ( !$fragments ) {
60                $fragments[] = $fragment;
61            } elseif ( $fragment === '..' && basename( $cur ) !== '..' ) {
62                $cur = dirname( $cur );
63            } elseif ( $fragment !== '.' ) {
64                $fragments[] = $fragment;
65            }
66
67            $path = $cur;
68        }
69
70        if ( $path !== '' ) {
71            $fragments[] = trim( $path, '/' );
72        }
73
74        return array_reverse( $fragments );
75    }
76
77    /**
78     * Return a relative filepath to path either from the current directory or from
79     * an optional start directory. Both paths must be absolute.
80     *
81     * @param string $path File path.
82     * @param string|null $start Start directory. Optional; if not specified, the current
83     *  working directory will be used.
84     * @return string|false Relative path, or false if input was invalid.
85     */
86    public static function getRelativePath( string $path, string $start = null ) {
87        if ( $start === null ) {
88            // @codeCoverageIgnoreStart
89            $start = getcwd();
90        }
91        // @codeCoverageIgnoreEnd
92
93        if ( substr( $path, 0, 1 ) !== '/' || substr( $start, 0, 1 ) !== '/' ) {
94            return false;
95        }
96
97        $pathParts = self::splitPath( $path );
98        $countPathParts = count( $pathParts );
99
100        $startParts = self::splitPath( $start );
101        $countStartParts = count( $startParts );
102
103        $commonLength = min( $countPathParts, $countStartParts );
104        for ( $i = 0; $i < $commonLength; $i++ ) {
105            if ( $startParts[$i] !== $pathParts[$i] ) {
106                break;
107            }
108        }
109
110        $relList = ( $countStartParts > $i )
111            ? array_fill( 0, $countStartParts - $i, '..' )
112            : [];
113
114        $relList = array_merge( $relList, array_slice( $pathParts, $i ) );
115
116        return implode( '/', $relList ) ?: '.';
117    }
118
119    /**
120     * Join two path components.
121     *
122     * This can be used to expand a path relative to a given base path.
123     * The given path may also be absolute, in which case it is returned
124     * directly.
125     *
126     * @code
127     *     RelPath::joinPath('/srv/foo', 'bar');        # '/srv/foo/bar'
128     *     RelPath::joinPath('/srv/foo', './bar');      # '/srv/foo/bar'
129     *     RelPath::joinPath('/srv//foo', '../baz');    # '/srv/baz'
130     *     RelPath::joinPath('/srv/foo', '/var/quux/'); # '/var/quux/'
131     * @endcode
132     *
133     * This function is similar to `os.path.join()` in Python,
134     * and `path.join()` in Node.js.
135     *
136     * @param string $base Base path.
137     * @param string $path File path to join to base path.
138     * @return string|false Combined path, or false if input was invalid.
139     */
140    public static function joinPath( string $base, string $path ) {
141        if ( substr( $path, 0, 1 ) === '/' ) {
142            // $path is absolute.
143            return $path;
144        }
145
146        if ( substr( $base, 0, 1 ) !== '/' ) {
147            // $base is relative.
148            return false;
149        }
150
151        $pathParts = self::splitPath( $path );
152        $resultParts = self::splitPath( $base );
153
154        // @phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
155        while ( ( $part = array_shift( $pathParts ) ) !== null ) {
156            switch ( $part ) {
157                case '.':
158                    break;
159                case '..':
160                    if ( count( $resultParts ) > 1 ) {
161                        array_pop( $resultParts );
162                    }
163                    break;
164                default:
165                    $resultParts[] = $part;
166                    break;
167            }
168        }
169
170        return implode( '/', $resultParts );
171    }
172}