• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

JBZoo / Path / 5450150073

pending completion
5450150073

push

github

web-flow
New codestyle and PHP 8.1+ (#7)

79 of 79 new or added lines in 1 file covered. (100.0%)

154 of 158 relevant lines covered (97.47%)

41.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.47
/src/Path.php
1
<?php
2

3
/**
4
 * JBZoo Toolbox - Path.
5
 *
6
 * This file is part of the JBZoo Toolbox project.
7
 * For the full copyright and license information, please view the LICENSE
8
 * file that was distributed with this source code.
9
 *
10
 * @license    MIT
11
 * @copyright  Copyright (C) JBZoo.com, All rights reserved.
12
 * @see        https://github.com/JBZoo/Path
13
 */
14

15
declare(strict_types=1);
16

17
namespace JBZoo\Path;
18

19
use JBZoo\Utils\Arr;
20
use JBZoo\Utils\FS;
21
use JBZoo\Utils\Sys;
22
use JBZoo\Utils\Url;
23

24
use function JBZoo\Utils\int;
25
use function JBZoo\Utils\isStrEmpty;
26

27
/**
28
 * @SuppressWarnings(PHPMD.ExcessiveClassComplexity)
29
 */
30
final class Path
31
{
32
    public const MIN_ALIAS_LENGTH = 2;
33

34
    // Modifiers adding rules
35
    public const MOD_PREPEND = 'prepend';
36
    public const MOD_APPEND  = 'append';
37
    public const MOD_RESET   = 'reset';
38

39
    /** Flag of result path (If true, is real path. If false, is relative path) */
40
    private bool $isReal = true;
41

42
    /** Holds paths list. */
43
    private array $paths = [];
44

45
    /** Root directory. */
46
    private ?string $root;
47

48
    public function __construct(?string $root = null)
49
    {
50
        $root = isStrEmpty($root) ? Sys::getDocRoot() : $root;
120✔
51
        $this->setRoot($root);
120✔
52
    }
53

54
    /**
55
     * Register alias locations in file system.
56
     * Example:
57
     *      "default:file.txt" - if added at least one path and
58
     *      "C:\server\test.dev\fy-folder" or "C:\server\test.dev\fy-folder\..\..\".
59
     */
60
    public function set(string $alias, array|string $paths, string $mode = self::MOD_PREPEND): self
61
    {
62
        $paths = (array)$paths;
80✔
63
        $alias = self::cleanAlias($alias);
80✔
64

65
        if (\strlen($alias) < self::MIN_ALIAS_LENGTH) {
80✔
66
            throw new Exception('The minimum number of characters is ' . self::MIN_ALIAS_LENGTH);
8✔
67
        }
68

69
        if ($alias === 'root') {
72✔
70
            throw new Exception('Alias "root" is predefined');
4✔
71
        }
72

73
        if ($mode === self::MOD_RESET) { // Reset mode
68✔
74
            $this->paths[$alias] = [];
4✔
75

76
            $mode = self::MOD_PREPEND; // Add new paths in Prepend mode
4✔
77
        }
78

79
        foreach ($paths as $path) {
68✔
80
            if (!isset($this->paths[$alias])) {
68✔
81
                $this->paths[$alias] = [];
68✔
82
            }
83

84
            $path = self::cleanPath($path);
68✔
85
            if ($path !== '' && !\in_array($path, $this->paths[$alias], true)) {
68✔
86
                if (\preg_match('/^' . \preg_quote($alias . ':', '') . '/i', $path) > 0) {
68✔
87
                    throw new Exception("Added looped path \"{$path}\" to key \"{$alias}\"");
4✔
88
                }
89

90
                $this->addNewPath($path, $alias, $mode);
64✔
91
            }
92
        }
93

94
        return $this;
64✔
95
    }
96

97
    /**
98
     * Get absolute path to a file or a directory.
99
     * @param string $source (example: "default:file.txt")
100
     */
101
    public function get(string $source): ?string
102
    {
103
        $parsedSource = $this->parse($source);
68✔
104

105
        return self::find($parsedSource[1], $parsedSource[2]);
68✔
106
    }
107

108
    /**
109
     * Get absolute path to a file or a directory.
110
     * @param string $source (example: "default:file.txt")
111
     */
112
    public function glob(string $source): ?array
113
    {
114
        $parsedSource = $this->parse($source);
8✔
115

116
        return self::findViaGlob($parsedSource[1], $parsedSource[2]);
8✔
117
    }
118

119
    /**
120
     * Get all absolute path to a file or a directory.
121
     * @param string $source (example: "default:file.txt")
122
     */
123
    public function getPaths(string $source): array
124
    {
125
        $source       = self::cleanSource($source);
36✔
126
        $parsedSource = $this->parse($source);
36✔
127

128
        return $parsedSource[1];
36✔
129
    }
130

131
    /**
132
     * Get root directory.
133
     */
134
    public function getRoot(): ?string
135
    {
136
        if ($this->root === null) {
16✔
137
            throw new Exception('Please, set the root directory');
×
138
        }
139

140
        return $this->root;
16✔
141
    }
142

143
    /**
144
     * Setup real or relative path flag.
145
     */
146
    public function setRealPathFlag(bool $isReal = true): self
147
    {
148
        $this->isReal = $isReal;
4✔
149

150
        return $this;
4✔
151
    }
152

153
    /**
154
     * Check virtual or real path.
155
     * @param string $path (example: "default:file.txt" or "C:\server\test.dev\file.txt")
156
     */
157
    public function isVirtual(string $path): bool
158
    {
159
        $parts = \explode(':', $path, 2);
76✔
160

161
        [$alias] = $parts;
76✔
162
        $alias   = self::cleanAlias($alias);
76✔
163
        if (!\array_key_exists($alias, $this->paths) && self::prefix($path) !== null) {
76✔
164
            return false;
72✔
165
        }
166

167
        $validNumberOfParts = 2;
40✔
168

169
        return \count($parts) === $validNumberOfParts;
40✔
170
    }
171

172
    /**
173
     * Remove path from registered paths for source.
174
     * @param string $fromSource (example: "default:file.txt")
175
     */
176
    public function remove(string $fromSource, array|string $paths): bool
177
    {
178
        $paths      = (array)$paths;
4✔
179
        $fromSource = self::cleanSource($fromSource);
4✔
180
        [$alias]    = $this->parse($fromSource);
4✔
181

182
        $return = false;
4✔
183

184
        foreach ($paths as $origPath) {
4✔
185
            $path = $this->cleanPathInternal(self::cleanPath($origPath));
4✔
186

187
            $key = \array_search($path, $this->paths[$alias], true);
4✔
188
            if ($key !== false) {
4✔
189
                unset($this->paths[$alias][$key]);
4✔
190
                $return = true;
4✔
191
            }
192
        }
193

194
        return $return;
4✔
195
    }
196

197
    /**
198
     * Setup root directory.
199
     */
200
    public function setRoot(?string $newRootPath): self
201
    {
202
        if ($newRootPath === '' || $newRootPath === null) {
120✔
203
            throw new Exception("New root path is empty: {$newRootPath}");
×
204
        }
205

206
        if (!\is_dir($newRootPath)) {
120✔
207
            throw new Exception("Directory not found: {$newRootPath}");
4✔
208
        }
209

210
        $this->root = self::cleanPath($newRootPath);
120✔
211

212
        return $this;
120✔
213
    }
214

215
    /**
216
     * Get url to a file.
217
     * @param string $source (example: "default:file.txt" or "C:\server\test.dev\file.txt")
218
     */
219
    public function url(string $source, bool $isFullUrl = true): ?string
220
    {
221
        $details = \explode('?', $source);
16✔
222

223
        $path = $this->cleanPathInternal($details[0] ?? '');
16✔
224

225
        if ($path !== '' && $path !== null) {
16✔
226
            $urlPath = $this->getUrlPath($path, true);
16✔
227

228
            if ($urlPath !== '' && $urlPath !== null) {
16✔
229
                if (isset($details[1])) {
16✔
230
                    $urlPath .= '?' . $details[1];
8✔
231
                }
232

233
                $urlPath = '/' . $urlPath;
16✔
234
                $root    = Url::root();
16✔
235

236
                return $isFullUrl ? "{$root}{$urlPath}" : $urlPath;
16✔
237
            }
238
        }
239

240
        return null;
4✔
241
    }
242

243
    /**
244
     * Get relative path to file or directory.
245
     * @param string $source (example: "default:file.txt")
246
     */
247
    public function rel(string $source): ?string
248
    {
249
        $fullpath = (string)$this->get($source);
8✔
250

251
        return FS::getRelative($fullpath, $this->root, '/');
8✔
252
    }
253

254
    /**
255
     * Get list of relative path to file or directory.
256
     * @param string $source (example: "default:*.txt")
257
     */
258
    public function relGlob(string $source): array
259
    {
260
        $list = (array)$this->glob($source);
4✔
261

262
        foreach ($list as $key => $item) {
4✔
263
            $list[$key] = FS::getRelative($item, $this->root, '/');
4✔
264
        }
265

266
        return $list;
4✔
267
    }
268

269
    /**
270
     * Normalize and clean path.
271
     * @param string $path ("C:\server\test.dev\file.txt")
272
     */
273
    public static function clean(string $path): string
274
    {
275
        $tokens      = [];
48✔
276
        $cleanedPath = self::cleanPath($path);
48✔
277

278
        $prefix      = (string)self::prefix($cleanedPath);
48✔
279
        $cleanedPath = \substr($cleanedPath, \strlen($prefix));
48✔
280

281
        $parts = \array_filter(\explode('/', $cleanedPath), static fn ($value) => $value);
48✔
282

283
        foreach ($parts as $part) {
48✔
284
            if ($part === '..') {
48✔
285
                \array_pop($tokens);
8✔
286
            } elseif ($part !== '.') {
48✔
287
                $tokens[] = $part;
48✔
288
            }
289
        }
290

291
        return $prefix . \implode('/', $tokens);
48✔
292
    }
293

294
    /**
295
     * Get path prefix.
296
     * @param string $path (example: "C:\server\test.dev\file.txt")
297
     */
298
    public static function prefix(string $path): ?string
299
    {
300
        $path = self::cleanPath($path);
92✔
301

302
        return \preg_match('|^(?P<prefix>([a-zA-Z]+:)?//?)|', $path, $matches) > 0
92✔
303
            ? $matches['prefix']
84✔
304
            : null;
92✔
305
    }
306

307
    /**
308
     * Add path to hold.
309
     * @param string $path (example: "default:file.txt" or "C:/Server/public_html/index.php")
310
     */
311
    private function addNewPath(string $path, string $alias, string $mode): self
312
    {
313
        $cleanPath = $this->cleanPathInternal($path);
64✔
314

315
        if ($cleanPath !== null && $cleanPath !== '') {
64✔
316
            if ($mode === self::MOD_PREPEND) {
64✔
317
                \array_unshift($this->paths[$alias], $cleanPath);
60✔
318
            }
319

320
            if ($mode === self::MOD_APPEND) {
64✔
321
                $this->paths[$alias][] = $cleanPath;
8✔
322
            }
323
        }
324

325
        return $this;
64✔
326
    }
327

328
    /**
329
     * Get add path.
330
     * @param string $path (example: "default:file.txt" or "C:/Server/public_html/index.php")
331
     */
332
    private function cleanPathInternal(string $path): ?string
333
    {
334
        if ($this->isVirtual($path)) {
68✔
335
            return self::cleanPath($path);
36✔
336
        }
337

338
        if (self::hasCDBack($path) > 0) {
64✔
339
            $realpath = self::cleanPath((string)\realpath($path));
36✔
340

341
            return $realpath !== '' ? $realpath : null;
36✔
342
        }
343

344
        return self::cleanPath($path);
64✔
345
    }
346

347
    /**
348
     * Get url path.
349
     * @param string $path (example: "default:file.txt" or "C:/Server/public_html/index.php")
350
     */
351
    private function getUrlPath(string $path, bool $exitsFile = false): ?string
352
    {
353
        if ($this->root === null || $this->root === '') {
16✔
354
            throw new Exception('Please, setup the root directory');
×
355
        }
356

357
        $path = $this->cleanPathInternal($path);
16✔
358
        if ($path !== null && $path !== '') {
16✔
359
            if ($this->isVirtual($path)) {
16✔
360
                $path = $this->get($path);
16✔
361
            }
362

363
            $subject = $path;
16✔
364
            $pattern = '/^' . \preg_quote($this->root, '/') . '/i';
16✔
365

366
            if (
367
                $path !== null
16✔
368
                && $path !== ''
16✔
369
                && $exitsFile
370
                && !$this->isVirtual($path)
16✔
371
                && !\file_exists($path)
16✔
372
            ) {
373
                $subject = null;
4✔
374
            }
375

376
            return \ltrim((string)\preg_replace($pattern, '', (string)$subject), '/');
16✔
377
        }
378

379
        return null;
×
380
    }
381

382
    /**
383
     * Parse source string.
384
     * @param string $source (example: "default:file.txt")
385
     */
386
    private function parse(string $source): array
387
    {
388
        $sourceParts = \explode(':', $source, 2);
72✔
389

390
        $alias = $sourceParts[0] ?? '';
72✔
391
        $path  = $sourceParts[1] ?? '';
72✔
392

393
        $path  = \ltrim($path, '\\/');
72✔
394
        $paths = $this->resolvePaths($alias);
72✔
395

396
        return [$alias, $paths, $path];
72✔
397
    }
398

399
    private function resolvePaths(string $alias): array
400
    {
401
        if ($alias === 'root') {
72✔
402
            return [$this->getRoot()];
8✔
403
        }
404

405
        $alias = self::cleanAlias($alias);
64✔
406

407
        $paths = $this->paths[$alias] ?? [];
64✔
408

409
        $result = [];
64✔
410

411
        foreach ($paths as $originalPath) {
64✔
412
            $realPath = $this->get($originalPath);
60✔
413
            if ($realPath !== null && $realPath !== '' && $this->isVirtual($originalPath)) {
60✔
414
                $path = $realPath;
4✔
415
            } else {
416
                $path = $this->cleanPathInternal($originalPath);
60✔
417
            }
418

419
            $result[] = $this->getCurrentPath((string)$path);
60✔
420
        }
421

422
        // remove empty && reset keys
423
        return \array_values(\array_filter($result));
64✔
424
    }
425

426
    /**
427
     * Get current resolve path.
428
     */
429
    private function getCurrentPath(string $path): ?string
430
    {
431
        $realpath    = \realpath($path);
60✔
432
        $realpath    = $realpath !== false ? $realpath : null;
60✔
433
        $currentPath = (string)($this->isReal ? $realpath : $path);
60✔
434

435
        return $currentPath !== '' ? $currentPath : null;
60✔
436
    }
437

438
    /**
439
     * Find actual file or directory in the paths.
440
     */
441
    private static function find(array|string $paths, string $file): ?string
442
    {
443
        $paths = (array)$paths;
68✔
444
        $file  = \ltrim($file, '\\/');
68✔
445

446
        foreach ($paths as $path) {
68✔
447
            $fullPath = self::clean($path . '/' . $file);
36✔
448

449
            if (\file_exists($fullPath) || \is_dir($fullPath)) {
36✔
450
                return $fullPath;
36✔
451
            }
452
        }
453

454
        return null;
60✔
455
    }
456

457
    /**
458
     * Find actual file or directory in the paths.
459
     */
460
    private static function findViaGlob(array|string $paths, string $file): array
461
    {
462
        $paths = (array)$paths;
8✔
463
        $file  = \ltrim($file, '\\/');
8✔
464

465
        $path = Arr::first($paths);
8✔
466

467
        $fullPath = self::clean($path . '/' . $file);
8✔
468

469
        $paths = \glob($fullPath, \GLOB_BRACE);
8✔
470

471
        return \array_filter((array)$paths);
8✔
472
    }
473

474
    /**
475
     * Check has back current.
476
     */
477
    private static function hasCDBack(string $path): int
478
    {
479
        $path = self::cleanPath($path);
64✔
480

481
        return int(\preg_match('(/\.\.$|/\.\./$)', $path));
64✔
482
    }
483

484
    private static function cleanAlias(string $alias): string
485
    {
486
        return (string)\preg_replace('/[^a-z0-9_.-]/i', '', $alias);
92✔
487
    }
488

489
    private static function cleanSource(string $source): string
490
    {
491
        $source = self::cleanAlias($source);
36✔
492
        $source .= ':';
36✔
493

494
        return $source;
36✔
495
    }
496

497
    /**
498
     * Forced clean path with linux-like slashes.
499
     */
500
    private static function cleanPath(?string $path): string
501
    {
502
        return FS::clean((string)$path, '/');
120✔
503
    }
504
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc