1<?php
2
3
4namespace ComboStrap;
5
6use ComboStrap\Web\Url;
7
8/**
9 * Class LocalPath
10 * @package ComboStrap
11 * A local file system path
12 *
13 * File protocol Uri:
14 *
15 * file://[HOST]/[PATH]
16 */
17class LocalPath extends PathAbs
18{
19
20
21    /**
22     * The characters that cannot be in the path for windows
23     * @var string[]
24     */
25    public const RESERVED_WINDOWS_CHARACTERS = ["\\", "/", ":", "*", "?", "\"", "<", ">", "|"];
26
27    const RELATIVE_CURRENT = ".";
28    const RELATIVE_PARENT = "..";
29    const LINUX_SEPARATOR = "/";
30    const WINDOWS_SEPARATOR = '\\';
31    const CANONICAL = "support";
32
33    /**
34     * @throws ExceptionBadArgument
35     */
36    public static function createFromUri($uri): LocalPath
37    {
38        if (strpos($uri, LocalFileSystem::SCHEME) !== 0) {
39            throw new ExceptionBadArgument("$uri is not a local path uri");
40        }
41        return new LocalPath($uri);
42    }
43
44
45    /**
46     * @throws ExceptionBadArgument
47     * @throws ExceptionCast
48     */
49    public static function createFromPathObject(Path $path): LocalPath
50    {
51        if ($path instanceof LocalPath) {
52            return $path;
53        }
54        if ($path instanceof WikiPath) {
55            return $path->toLocalPath();
56        }
57        throw new ExceptionBadArgument("The path is not a local path nor a wiki path, we can't transform it");
58    }
59
60    /**
61     *
62     * @throws ExceptionNotFound - if the env directory is not found
63     */
64    public static function createDesktopDirectory(): LocalPath
65    {
66        return LocalPath::createHomeDirectory()->resolve("Desktop");
67    }
68
69
70    public function toUriString(): string
71    {
72        return $this->getUrl()->toString();
73    }
74
75    private $path;
76    /**
77     * @var mixed
78     */
79    private $sep = DIRECTORY_SEPARATOR;
80
81    private ?string $host = null;
82
83    /**
84     * LocalPath constructor.
85     * @param string $path - relative or absolute, or a locale file uri
86     * @param string|null $sep - the directory separator - it permits to test linux path on windows, and vice-versa
87     */
88    public function __construct(string $path, string $sep = null)
89    {
90        /**
91         * php mon amour,
92         * if we pass a {@link LocalPath}, no error,
93         * it just pass the {@link PathAbs::__toString()}
94         */
95        if (strpos($path, LocalFileSystem::SCHEME.'://') === 0) {
96            try {
97                $path = Url::createFromString($path)->getPath();
98                LogUtility::errorIfDevOrTest("The path given as constructor should not be an uri or a path object");
99            } catch (ExceptionBadArgument|ExceptionBadSyntax|ExceptionNotFound $e) {
100                LogUtility::internalError("The uri path could not be created", self::CANONICAL, $e);
101            }
102        }
103        if ($sep != null) {
104            $this->sep = $sep;
105        }
106        // The network share windows/wiki styles with with two \\ and not //
107        $networkShare = "\\\\";
108        if (substr($path, 0, 2) === $networkShare) {
109            // window share
110            $pathWithoutNetworkShare = substr($path, 2);
111            $pathWithoutNetworkShare = str_replace("\\", "/", $pathWithoutNetworkShare);
112            [$this->host, $relativePath] = explode("/", $pathWithoutNetworkShare, 2);
113            $this->path = "/$relativePath";
114            return;
115        }
116        $this->path = self::normalizeToOsSeparator($path);
117    }
118
119
120    /**
121     * @param string $filePath
122     * @return LocalPath
123     * @deprecated for {@link LocalPath::createFromPathString()}
124     */
125    public static function create(string $filePath): LocalPath
126    {
127        return new LocalPath($filePath);
128    }
129
130    /**
131     * @param $path
132     * @return array|string|string[]
133     *
134     * For whatever reason, it seems that php/dokuwiki uses always the / separator on windows also
135     * but not always (ie  https://www.php.net/manual/en/function.realpath.php output \ on windows)
136     *
137     * Because we want to be able to copy the path value and to be able to use
138     * it directly, we normalize it to the OS separator at build time
139     */
140    private function normalizeToOsSeparator($path)
141    {
142        if ($path === self::RELATIVE_CURRENT || $path === self::RELATIVE_PARENT) {
143            return realpath($path);
144        }
145        $directorySeparator = $this->getDirectorySeparator();
146        if ($directorySeparator === self::WINDOWS_SEPARATOR) {
147            return str_replace(self::LINUX_SEPARATOR, self::WINDOWS_SEPARATOR, $path);
148        } else {
149            return str_replace(self::WINDOWS_SEPARATOR, self::LINUX_SEPARATOR, $path);
150        }
151    }
152
153    /**
154     * @throws ExceptionNotFound
155     */
156    public static function createHomeDirectory(): LocalPath
157    {
158        $home = getenv("HOME");
159        if ($home === false) {
160            $home = getenv("USERPROFILE");
161        }
162        if ($home === false) {
163            throw new ExceptionNotFound(" The home directory variable could not be found");
164        }
165        return LocalPath::createFromPathString($home);
166    }
167
168
169    public static function createFromPathString(string $string, string $sep = null): LocalPath
170    {
171        return new LocalPath($string, $sep);
172    }
173
174    function getScheme(): string
175    {
176        return LocalFileSystem::SCHEME;
177    }
178
179    function getLastName(): string
180    {
181        $names = $this->getNames();
182        $sizeof = sizeof($names);
183        if ($sizeof === 0) {
184            throw new ExceptionNotFound("No last name for the path ($this)");
185        }
186        return $names[$sizeof - 1];
187
188    }
189
190
191    public function getExtension(): string
192    {
193        $extension = pathinfo($this->path, PATHINFO_EXTENSION);
194        if ($extension === "") {
195            throw new ExceptionNotFound("No extension found for the path ($this)");
196        }
197        return $extension;
198    }
199
200    function getNames()
201    {
202        $directorySeparator = $this->getDirectorySeparator();
203        return explode($directorySeparator, $this->path);
204    }
205
206
207    function toAbsoluteId(): string
208    {
209        return $this->path;
210    }
211
212    public function getParent(): Path
213    {
214        $absolutePath = pathinfo($this->path, PATHINFO_DIRNAME);
215        if ($absolutePath === $this->path || empty($absolutePath)) {
216            // the directory on windows of the root (ie C:\) is (C:\), yolo !
217            throw new ExceptionNotFound("No parent");
218        }
219        return new LocalPath($absolutePath);
220    }
221
222    function toAbsolutePath(): Path
223    {
224
225        if ($this->isAbsolute()) {
226            return $this;
227        }
228
229        return $this->toCanonicalAbsolutePath();
230
231    }
232
233
234    /**
235     * @throws ExceptionBadArgument - if the path is not inside a drive
236     */
237    public function toWikiPath(): WikiPath
238    {
239        return WikiPath::createFromPathObject($this);
240    }
241
242    public function resolve(string $name): LocalPath
243    {
244
245        $newPath = $this->toCanonicalAbsolutePath()->toAbsoluteId() . $this->getDirectorySeparator() . utf8_encodeFN($name);
246        return self::createFromPathString($newPath);
247
248    }
249
250    /**
251     * @throws ExceptionBadArgument - if the path cannot be relativized
252     */
253    public function relativize(LocalPath $localPath): LocalPath
254    {
255
256        /**
257         * One of the problem of relativization is
258         * that it may be:
259         * * logical (when using a symlink)
260         * * physical
261         */
262        if (!$this->isAbsolute() || $this->isShortName()) {
263            /**
264             * This is not a logical resolution
265             * (if the path is logically not absolute and is a symlink,
266             * we have a problem)
267             */
268            $actualPath = $this->toCanonicalAbsolutePath();
269        } else {
270            $actualPath = $this;
271        }
272        if (!$localPath->isAbsolute() || $localPath->isShortName()) {
273            $localPath = $localPath->toCanonicalAbsolutePath();
274        }
275
276        if (strpos($actualPath->toAbsoluteId(), $localPath->toAbsoluteId()) === 0) {
277            if ($actualPath->toAbsoluteId() === $localPath->toAbsoluteId()) {
278                return LocalPath::createFromPathString("");
279            }
280            $sepCharacter = 1; // delete the sep characters
281            $relativePath = substr($actualPath->toAbsoluteId(), strlen($localPath->toAbsoluteId()) + $sepCharacter);
282            $relativePath = str_replace($this->getDirectorySeparator(), WikiPath::NAMESPACE_SEPARATOR_DOUBLE_POINT, $relativePath);
283            return LocalPath::createFromPathString($relativePath);
284        }
285        /**
286         * May be a symlink link
287         */
288        if ($this->isSymlink()) {
289            $realPath = $this->toCanonicalAbsolutePath();
290            return $realPath->relativize($localPath);
291        }
292        if ($localPath->isSymlink()) {
293            $localPath = $localPath->toCanonicalAbsolutePath();
294            $this->relativize($localPath);
295        }
296        throw new ExceptionBadArgument("The path ($localPath) is not a parent path of the actual path ($actualPath)");
297
298    }
299
300    public function isAbsolute(): bool
301    {
302        /**
303         * /
304         * or a-z:\
305         */
306        if (preg_match("/^(\/|[a-z]:\\\\?).*/i", $this->path)) {
307            return true;
308        }
309        return false;
310
311    }
312
313    /**
314     * An absolute path may not be canonical
315     * (ie windows short name or the path separator is not consistent (ie / in place of \ on windows)
316     *
317     * This function makes the path canonical meaning that two canonical path can be compared.
318     * This is also needed when you path a path string to a php function such as `clearstatcache`
319     *
320     * If this is a symlink, it will resolve it to the real path
321     */
322    public function toCanonicalAbsolutePath(): LocalPath
323    {
324
325        /**
326         * realpath() is just a system/library call to actual realpath() function supported by OS.
327         * real path handle also the windows name ie USERNAME~
328         */
329        $isSymlink = $this->isSymlink();
330        $realPath = realpath($this->path);
331        if($isSymlink){
332            /**
333             *
334             * What fucked is fucked up
335             *
336             * With the symlink
337             * D:/dokuwiki-animals/combo.nico.lan/data/pages
338             * if you pass it to realpath:
339             * ```
340             * realpath("D:/dokuwiki-animals/combo.nico.lan/data/pages")
341             * ```
342             * you get: `d:\dokuwiki\website\pages`
343             * if you pass the result again in realpath
344             * ```
345             * realpath(d:\dokuwiki\website\pages)
346             * ```
347             * we get another result `D:\dokuwiki\website\pages`
348             *
349             */
350            $realPath = realpath($realPath);
351        }
352        if ($realPath !== false) {
353            return LocalPath::createFromPathString($realPath);
354        }
355
356        /**
357         * It returns false on on file that does not exists.
358         * The suggestion on the realpath man page
359         * is to look for an existing parent directory.
360         * https://man7.org/linux/man-pages/man3/realpath.3.html
361         */
362        $parts = null;
363        $isRoot = false;
364        $counter = 0; // breaker
365        $workingPath = $this->path;
366        while ($realPath === false) {
367            $counter++;
368            $parent = dirname($workingPath);
369            /**
370             * From the doc: https://www.php.net/manual/en/function.dirname.php
371             * dirname('.');    // Will return '.'.
372             * dirname('/');    // Will return `\` on Windows and '/' on *nix systems.
373             * dirname('\\');   // Will return `\` on Windows and '.' on *nix systems.
374             * dirname('C:\\'); // Will return 'C:\' on Windows and '.' on *nix systems.
375             * dirname('\');    // Will return `C:\` on Windows and ??? on *nix systems.
376             */
377            if (preg_match("/^(\.|\/|\\\\|[a-z]:\\\\)$/i", $parent)
378                || $parent === $workingPath
379                || $parent === "\\" // bug on regexp
380            ) {
381                $isRoot = true;
382            }
383            // root, no need to delete the last sep
384            $lastSep = 1;
385            if ($isRoot) {
386                $lastSep = 0;
387            }
388            $parts[] = substr($workingPath, strlen($parent) + $lastSep);
389
390            $realPath = realpath($parent);
391            if ($isRoot) {
392                break;
393            }
394            if ($counter > 200) {
395                $message = "Bad absolute local path file ($this->path)";
396                if (PluginUtility::isDevOrTest()) {
397                    throw new ExceptionRuntime($message);
398                } else {
399                    LogUtility::msg($message);
400                }
401                return $this;
402            }
403            if ($realPath === false) {
404                // loop
405                $workingPath = $parent;
406            }
407        }
408        if ($parts !== null) {
409            if (!$isRoot) {
410                $realPath .= $this->getDirectorySeparator();
411            }
412            $parts = array_reverse($parts);
413            $realPath .= implode($this->getDirectorySeparator(), $parts);
414        }
415        return LocalPath::createFromPathString($realPath);
416    }
417
418    public function getDirectorySeparator()
419    {
420        return $this->sep;
421    }
422
423
424    function getUrl(): Url
425    {
426
427        /**
428         * file://host/path
429         */
430        $uri = LocalFileSystem::SCHEME . '://';
431        try {
432            // Windows share host
433            $uri = "$uri{$this->getHost()}";
434        } catch (ExceptionNotFound $e) {
435            // ok
436        }
437        $pathNormalized = str_replace(self::WINDOWS_SEPARATOR, self::LINUX_SEPARATOR, $this->path);
438        if ($pathNormalized[0] !== "/") {
439            $uri = $uri . "/" . $pathNormalized;
440        } else {
441            $uri = $uri . $pathNormalized;
442        }
443        try {
444            return Url::createFromString($uri);
445        } catch (ExceptionBadSyntax|ExceptionBadArgument $e) {
446            $message = "Local Uri Path has a bad syntax ($uri)";
447            // should not happen
448            LogUtility::internalError($message);
449            throw new ExceptionRuntime($message);
450        }
451
452    }
453
454    /**
455     * @throws ExceptionNotFound
456     */
457    function getHost(): string
458    {
459        if ($this->host === null) {
460            throw new ExceptionNotFound("No host. Localhost should be the default");
461        }
462        return $this->host;
463    }
464
465    public function isSymlink(): bool
466    {
467        return is_link($this->path);
468    }
469
470    private function isShortName(): bool
471    {
472        /**
473         * See short name in windows
474         * https://datacadamia.com/os/windows/path#pathname
475         */
476        return strpos($this->path, "~1") !== false;
477    }
478}
479