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

olinox14 / path-php / 8333472737

18 Mar 2024 08:53PM UTC coverage: 99.438%. Remained the same
8333472737

push

github

olinox14
complete composer.json

354 of 356 relevant lines covered (99.44%)

16.8 hits per line

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

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

3
namespace Path;
4

5
use Generator;
6
use Path\Exception\FileExistsException;
7
use Path\Exception\FileNotFoundException;
8
use Path\Exception\IOException;
9
use RuntimeException;
10
use Throwable;
11

12
/**
13
 * Represents a file or directory path.
14
 *
15
 * @package olinox14/path
16
 */
17
class Path
18
{
19
    /**
20
     * File exists
21
     */
22
    const F_OK = 0;
23
    /**
24
     * Has read permission on the file
25
     */
26
    const R_OK = 4;
27
    /**
28
     * Has write permission on the file
29
     */
30
    const W_OK = 2;
31
    /**
32
     * Has execute permission on the file
33
     */
34
    const X_OK = 1;
35

36
    protected string $path;
37

38
    protected mixed $handle;
39
    protected BuiltinProxy $builtin;
40

41
    /**
42
     * Joins two or more parts of a path together, inserting '/' as needed.
43
     * If any component is an absolute path, all previous path components
44
     * will be discarded. An empty last part will result in a path that
45
     * ends with a separator.
46
     *
47
     * @param string|Path $path The base path
48
     * @param string ...$parts The parts of the path to be joined.
49
     * @return self The resulting path after joining the parts using the directory separator.
50
     *  TODO: manual test
51
     */
52
    public static function join(string|self $path, string|self ...$parts): string
53
    {
54
        $path = (string)$path;
20✔
55
        $parts = array_map(fn($p) => (string)$p, $parts);
20✔
56

57
        foreach ($parts as $part) {
20✔
58
            if (str_starts_with($part, DIRECTORY_SEPARATOR)) {
20✔
59
                $path = $part;
12✔
60
            } elseif (!$path || str_ends_with($path, DIRECTORY_SEPARATOR)) {
16✔
61
                $path .= $part;
8✔
62
            } else {
63
                $path .= DIRECTORY_SEPARATOR . $part;
16✔
64
            }
65
        }
66
        return new self($path);
20✔
67
    }
68

69
    public function __construct(string|self $path)
70
    {
71
        $this->builtin = new BuiltinProxy();
708✔
72
        
73
        $this->path = (string)$path;
708✔
74
        $this->handle = null;
708✔
75
        return $this;
708✔
76
    }
77

78
    /**
79
     * @return string
80
     *  TODO: manual test
81
     */
82
    public function __toString(): string {
83
        return $this->path;
60✔
84
    }
85

86
    /**
87
     * Casts the input into an instance of the current class.
88
     *
89
     * @param string|self $path The input path to be cast.
90
     * @return self An instance of the current class.
91
     *  TODO: manual test
92
     */
93
    protected function cast(string|self $path): self
94
    {
95
        return new self($path);
8✔
96
    }
97

98
    /**
99
     * Retrieves the current path of the file or directory
100
     *
101
     * @return string The path of the file or directory
102
     *  TODO: manual test
103
     */
104
    public function path(): string
105
    {
106
        return $this->path;
24✔
107
    }
108

109
    /**
110
     * Checks if the given path is equal to the current path.
111
     *
112
     * @param string|Path $path The path to compare against.
113
     *
114
     * @return bool Returns true if the given path is equal to the current path, false otherwise.
115
     *  TODO: manual test
116
     */
117
    public function eq(string|self $path): bool {
118
        return $this->cast($path)->path() === $this->path();
4✔
119
    }
120

121
    /**
122
     * Appends parts to the current path.
123
     *
124
     *  TODO: manual test
125
     * @param string ...$parts The parts to be appended to the current path.
126
     * @return self Returns an instance of the class with the appended path.
127
     *@see Path::join()
128
     *
129
     */
130
    public function append(string|self ...$parts): self
131
    {
132
        $this->path = self::join($this->path, ...$parts);
12✔
133
        return $this;
12✔
134
    }
135

136
    /**
137
     * Returns an absolute version of the current path.
138
     *
139
     * @return self
140
     * @throws IOException
141
     * TODO: manual test
142
     */
143
    public function absPath(): self
144
    {
145
        $absPath = $this->builtin->realpath($this->path);
8✔
146
        if ($absPath === false) {
8✔
147
            throw new IOException("Error while getting abspath of `" . $this->path . "`");
4✔
148
        }
149
        return $this->cast($absPath);
4✔
150
    }
151

152
    /**
153
     * > Alias for absPath()
154
     * @throws IOException
155
     *  TODO: manual test
156
     */
157
    public function realpath(): self
158
    {
159
        return $this->absPath();
4✔
160
    }
161

162
    /**
163
     * Checks the access rights for a given file or directory.
164
     * From the python `os.access` method
165
     *
166
     * @param int $mode The access mode to check. Permitted values:
167
     *        - F_OK: checks for the existence of the file or directory.
168
     *        - R_OK: checks for read permission.
169
     *        - W_OK: checks for write permission.
170
     *        - X_OK: checks for execute permission.
171
     * @return bool Returns true if the permission check is successful; otherwise, returns false.
172
     *  TODO: manual test
173
     */
174
    function access(int $mode): bool
175
    {
176
        return match ($mode) {
20✔
177
            self::F_OK => $this->builtin->file_exists($this->path),
20✔
178
            self::R_OK => $this->builtin->is_readable($this->path),
20✔
179
            self::W_OK => $this->builtin->is_writable($this->path),
20✔
180
            self::X_OK => $this->builtin->is_executable($this->path),
20✔
181
            default => throw new RuntimeException('Invalid mode'),
20✔
182
        };
20✔
183
    }
184

185
    /**
186
     * Retrieves the last access time of a file or directory.
187
     *
188
     * @return int The last access time of the file or directory as a timestamp.
189
     * @throws IOException
190
     * @throws FileNotFoundException
191
     *  TODO: manual test
192
     */
193
    function atime(): int
194
    {
195
        if (!$this->exists()) {
12✔
196
            throw new FileNotFoundException('File does not exists : ' . $this->path);
4✔
197
        }
198
        $time = $this->builtin->fileatime($this->path);
8✔
199
        if ($time === false) {
8✔
200
            throw new IOException('Could not get the last access time of ' . $this->path);
4✔
201
        }
202
        return $time;
4✔
203
    }
204

205
    /**
206
     * Retrieves the creation time of a file or directory.
207
     *
208
     * @return int The creation time of the file or directory as a timestamp.
209
     * @throws FileNotFoundException
210
     * @throws IOException
211
     *  TODO: manual test
212
     */
213
    function ctime(): int
214
    {
215
        if (!$this->exists()) {
12✔
216
            throw new FileNotFoundException('File does not exists : ' . $this->path);
4✔
217
        }
218
        $time = $this->builtin->filectime($this->path);
8✔
219
        if ($time === false) {
8✔
220
            throw new IOException('Could not get the creation time of ' . $this->path);
4✔
221
        }
222
        return $time;
4✔
223
    }
224

225
    /**
226
     * Retrieves the last modified time of a file or directory.
227
     *
228
     * @return int The last modified time of the file or directory as a timestamp.
229
     * @throws FileNotFoundException
230
     * @throws IOException
231
     *  TODO: manual test
232
     */
233
    function mtime(): int
234
    {
235
        if (!$this->exists()) {
12✔
236
            throw new FileNotFoundException('File does not exists : ' . $this->path);
4✔
237
        }
238
        $time = $this->builtin->filemtime($this->path);
8✔
239
        if ($time === false) {
8✔
240
            throw new IOException('Could not get the creation time of ' . $this->path);
4✔
241
        }
242
        return $time;
4✔
243
    }
244

245
    /**
246
     * Check if the path refers to a regular file.
247
     *
248
     * @return bool Returns true if the path refers to a regular file, otherwise returns false.
249
     *  TODO: manual test
250
     */
251
    public function isFile(): bool
252
    {
253
        return $this->builtin->is_file($this->path);
4✔
254
    }
255

256
    /**
257
     * Check if the given path is a directory.
258
     *
259
     * @return bool Returns true if the path is a directory, false otherwise.
260
     *  TODO: manual test
261
     */
262
    public function isDir(): bool
263
    {
264
        return $this->builtin->is_dir($this->path);
4✔
265
    }
266

267
    /**
268
     * Get the extension of the given path.
269
     *
270
     * @return string Returns the extension of the path as a string if it exists, or an empty string otherwise.
271
     *  TODO: manual test
272
     */
273
    public function ext(): string
274
    {
275
        return $this->builtin->pathinfo($this->path, PATHINFO_EXTENSION);
8✔
276
    }
277

278
    /**
279
     * Get the base name of the path.
280
     *
281
     * Ex: Path('path/to/file.ext').basename() => 'file.ext'
282
     *
283
     * @return string The base name of the path.
284
     *  TODO: manual test
285
     */
286
    public function basename(): string
287
    {
288
        return $this->builtin->pathinfo($this->path, PATHINFO_BASENAME);
4✔
289
    }
290

291
    /**
292
     * Changes the current working directory to this path.
293
     *
294
     * @throws FileNotFoundException
295
     * @throws IOException
296
     *  TODO: manual test
297
     */
298
    public function cd(): void
299
    {
300
        if (!$this->isDir()) {
12✔
301
            throw new FileNotFoundException("Dir does not exist : " . $this->path);
4✔
302
        }
303
        $result = $this->builtin->chdir($this->path);
8✔
304
        if (!$result) {
8✔
305
            throw new IOException('Error while changing working directory to ' . $this->path);
4✔
306
        }
307
    }
308

309
    /**
310
     * > Alias for Path->cd($path)
311
     *
312
     * @throws FileNotFoundException
313
     * @throws IOException
314
     *  TODO: manual test
315
     */
316
    public function chdir(): void
317
    {
318
        $this->cd();
4✔
319
    }
320

321
    /**
322
     * Get the name of the file or path.
323
     *
324
     * Ex: Path('path/to/file.ext').name() => 'file'
325
     *
326
     * @return string Returns the name of the file without its extension.
327
     *  TODO: manual test
328
     */
329
    public function name(): string
330
    {
331
        return $this->builtin->pathinfo($this->path, PATHINFO_FILENAME);
4✔
332
    }
333

334
    /**
335
     * Converts the path to the normalized form.
336
     *
337
     * @return self The instance of the current object.
338
     *  TODO: manual test
339
     */
340
    public function normCase(): self
341
    {
342
        return $this->cast(
4✔
343
            strtolower(
4✔
344
                str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $this->path())
4✔
345
            )
4✔
346
        );
4✔
347
    }
348

349
    /**
350
     * Normalizes the path of the file or directory.
351
     *
352
     * > Thanks to https://stackoverflow.com/users/216254/troex
353
     * @return self A new instance of the class with the normalized path.
354
     *  TODO: manual test
355
     */
356
    public function normPath(): self
357
    {
358
        $path = $this->normCase()->path();
32✔
359

360
        // TODO: handle case where path start with //
361
        if (empty($path)) {
32✔
362
            return $this->cast('.');
4✔
363
        }
364

365
        // Also tests some special cases we can't really do anything with
366
        if (!str_contains($path, '/') || $path === '/' || '.' === $path || '..' === $path) {
28✔
367
            return $this->cast($path);
16✔
368
        }
369

370
        $path = rtrim($path, '/');
12✔
371

372
        // Extract the scheme if any
373
        $scheme = null;
12✔
374
        if (strpos($path, '://')) {
12✔
375
            list($scheme, $path) = explode('://', $path, 2);
×
376
        }
377

378
        $parts = explode('/', $path);
12✔
379
        $newParts = [];
12✔
380

381
        foreach ($parts as $part) {
12✔
382

383
            if ($part === '' || $part === '.') {
12✔
384
                if (empty($newParts)) {
8✔
385
                    // First part is empty or '.' : path is absolute, keep an empty first part. Else, discard.
386
                    $newParts[] = '';
8✔
387
                }
388
                continue;
8✔
389
            }
390

391
            if ($part === '..') {
12✔
392
                if (empty($newParts)) {
8✔
393
                    // Path start with '..', we can't do anything with it so keep it
394
                    $newParts[] = $part;
4✔
395
                } else {
396
                    // Remove the last part
397
                    array_pop($newParts);
8✔
398
                }
399
                continue;
8✔
400
            }
401

402
            $newParts[] = $part;
12✔
403
        }
404

405
        // Rebuild path
406
        $newPath = implode('/', $newParts);
12✔
407

408
        // Add scheme if any
409
        if ($scheme !== null) {
12✔
410
            $newPath = $scheme . '://' . $newPath;
×
411
        }
412

413
        return $this->cast($newPath);
12✔
414
    }
415

416
    /**
417
     * Creates a new directory.
418
     *
419
     * @param int $mode The permissions for the new directory. Default is 0777.
420
     * @param bool $recursive Indicates whether to create parent directories if they do not exist. Default is false.
421
     *
422
     * @return void
423
     * @throws FileExistsException
424
     * @throws IOException
425
     *  TODO: manual test
426
     */
427
    public function mkdir(int $mode = 0777, bool $recursive = false): void
428
    {
429
        if ($this->isDir()) {
24✔
430
            if (!$recursive) {
8✔
431
                throw new FileExistsException("Directory already exists : " . $this);
4✔
432
            } else {
433
                return;
4✔
434
            }
435
        }
436

437
        if ($this->isFile()) {
16✔
438
            throw new FileExistsException("A file with this name already exists : " . $this);
8✔
439
        }
440

441
        $result = $this->builtin->mkdir($this->path, $mode, $recursive);
8✔
442

443
        if (!$result) {
8✔
444
            throw new IOException("Error why creating the new directory : " . $this->path);
4✔
445
        }
446
    }
447

448
    /**
449
     * Deletes a file or a directory (non-recursively).
450
     *
451
     * @return void
452
     * @throws FileNotFoundException
453
     * @throws IOException
454
     *  TODO: manual test
455
     */
456
    public function delete(): void
457
    {
458
        if ($this->isFile()) {
20✔
459
            $result = $this->builtin->unlink($this->path);
8✔
460

461
            if (!$result) {
8✔
462
                throw new IOException("Error why deleting file : " . $this->path);
6✔
463
            }
464
        } else if ($this->isDir()) {
12✔
465
            $result = $this->builtin->rmdir($this->path);
8✔
466

467
            if (!$result) {
8✔
468
                throw new IOException("Error why deleting directory : " . $this->path);
6✔
469
            }
470
        } else {
471
            throw new FileNotFoundException("File or directory does not exist : " . $this);
4✔
472
        }
473
    }
474

475
    /**
476
     * Copy data and mode bits (“cp src dst”). The destination may be a directory.
477
     * Return the file’s destination as a Path.
478
     * If follow_symlinks is false, symlinks won’t be followed. This resembles GNU’s “cp -P src dst”.
479
     *
480
     * @param string|self $destination The destination path or object to copy the file to.
481
     * @throws FileNotFoundException If the source file does not exist or is not a file.
482
     * @throws FileExistsException
483
     * @throws IOException
484
     *  TODO: manual test
485
     */
486
    public function copy(string|self $destination, bool $follow_symlinks = false): self
487
    {
488
        if (!$this->isFile()) {
32✔
489
            throw new FileNotFoundException("File does not exist or is not a file : " . $this);
4✔
490
        }
491

492
        $destination = $this->cast($destination);
28✔
493
        if ($destination->isDir()) {
28✔
494
            $destination = $destination->append($this->basename());
8✔
495
        }
496

497
        if ($destination->isFile()) {
28✔
498
            throw new FileExistsException("File already exists : " . $destination->path());
8✔
499
        }
500

501
        if (!$follow_symlinks && $this->isLink()) {
20✔
502
            return $this->symlink($destination);
4✔
503
        }
504

505
        $success = $this->builtin->copy($this->path, $destination->path());
16✔
506
        if (!$success) {
16✔
507
            throw new IOException("Error copying file {$this->path} to {$destination->path()}");
4✔
508
        }
509

510
        return $destination;
12✔
511
    }
512

513
    /**
514
     * Copies the content of a file or directory to the specified destination.
515
     *
516
     * @param string|self $destination The destination path or directory to copy the content to.
517
     * @param bool $follow_symlinks (Optional) Whether to follow symbolic links.
518
     * @return self The object on which the method is called.
519
     * @throws FileExistsException If the destination path or directory already exists.
520
     * @throws FileNotFoundException If the source file or directory does not exist.
521
     * @throws IOException
522
     *  TODO: manual test
523
     */
524
    public function copyTree(string|self $destination, bool $follow_symlinks = false): self
525
    {
526
        if (!$this->exists()) {
16✔
527
            throw new FileNotFoundException("File or dir does not exist : " . $this);
4✔
528
        }
529

530
        if ($this->isFile()) {
12✔
531
            return $this->copy($destination, $follow_symlinks);
4✔
532
        }
533

534
        $destination = $this->cast($destination);
8✔
535

536
        foreach ($this->files() as $file) {
8✔
537
            $file->copy($destination, $follow_symlinks);
8✔
538
        }
539

540
        foreach ($this->dirs() as $dir) {
8✔
541
            $dir->mkdir(); // TODO: mimic the source permissions into the new dir?
4✔
542
            $dir->copyTree($destination, $follow_symlinks);
4✔
543
        }
544

545
        return $destination;
8✔
546
    }
547

548
    /**
549
     * Moves a file or directory to a new location. Existing files or dirs will be overwritten.
550
     * Returns the path of the newly created file or directory.
551
     *
552
     * @param string|Path $destination The new location where the file or directory should be moved to.
553
     *
554
     * @return Path
555
     * @throws IOException
556
     * @throws FileNotFoundException
557
     *  TODO: manual test
558
     */
559
    public function move(string|self $destination): self
560
    {
561
        if (!$this->exists()) {
20✔
562
            throw new FileNotFoundException($this->path . " does not exist");
4✔
563
        }
564

565
        $destination = $this->cast($destination);
16✔
566

567
        if ($destination->isDir()) {
16✔
568
            $destination = $destination->append($this->basename());
4✔
569
        }
570

571
        $success = $this->builtin->rename($this->path, $destination->path());
16✔
572

573
        if (!$success) {
16✔
574
            throw new IOException("Error while moving " . $this->path . " to " . $destination->path());
4✔
575
        }
576

577
        return $destination;
12✔
578
    }
579

580
    /**
581
     * Updates the access and modification time of a file or creates a new empty file if it doesn't exist.
582
     *
583
     * @param int|\DateTime|null $time (optional) The access and modification time to set. Default is the current time.
584
     * @param int|\DateTime|null $atime (optional) The access time to set. Default is the value of $time.
585
     *
586
     * @return void
587
     * @throws IOException
588
     *  TODO: manual test
589
     */
590
    public function touch(int|\DateTime $time = null, int|\DateTime $atime = null): void
591
    {
592
        if ($time instanceof \DateTime) {
16✔
593
            $time = $time->getTimestamp();
4✔
594
        }
595
        if ($atime instanceof \DateTime) {
16✔
596
            $atime = $atime->getTimestamp();
4✔
597
        }
598

599
        $success = $this->builtin->touch($this->path, $time, $atime);
16✔
600

601
        if (!$success) {
16✔
602
            throw new IOException("Error while touching " . $this->path);
4✔
603
        }
604
    }
605

606
    /**
607
     * Calculates the size of a file.
608
     *
609
     * @return int The size of the file in bytes.
610
     * @throws FileNotFoundException
611
     * @throws IOException
612
     *  TODO: manual test
613
     */
614
    public function size(): int
615
    {
616
        if (!$this->isFile()) {
12✔
617
            throw new FileNotFoundException("File does not exist : " . $this->path);
4✔
618
        }
619

620
        $result = $this->builtin->filesize($this->path);
8✔
621

622
        if ($result === false) {
8✔
623
            throw new IOException("Error while getting the size of " . $this->path);
4✔
624
        }
625

626
        return $result;
4✔
627
    }
628

629
    /**
630
     * Retrieves the parent directory of a file or directory path.
631
     *
632
     * @return self The parent directory of the specified path.
633
     *  TODO: manual test
634
     */
635
    public function parent(int $levels = 1): self
636
    {
637
        return $this->cast(
4✔
638
            $this->builtin->dirname($this->path ?? ".", $levels)
4✔
639
        );
4✔
640
    }
641

642
    /**
643
     * Alias for Path->parent() method
644
     *
645
     * @param int $levels
646
     * @return self
647
     *  TODO: manual test
648
     */
649
    public function dirname(int $levels = 1): self
650
    {
651
        return $this->parent($levels);
4✔
652
    }
653

654
    /**
655
     * Retrieves an array of this directory’s subdirectories.
656
     *
657
     * The elements of the list are Path objects.
658
     * This does not walk recursively into subdirectories (but see walkdirs())
659
     *
660
     * @return array
661
     * @throws FileNotFoundException
662
     *  TODO: manual test
663
     */
664
    public function dirs(): array
665
    {
666
        if (!$this->isDir()) {
8✔
667
            throw new FileNotFoundException("Directory does not exist: " . $this->path);
4✔
668
        }
669

670
        $dirs = [];
4✔
671

672
        foreach ($this->builtin->scandir($this->path) as $filename) {
4✔
673
            if ($filename === '.' || $filename === '..') {
4✔
674
                continue;
4✔
675
            }
676

677
            $child = $this->append($filename);
4✔
678

679
            if ($child->isDir()) {
4✔
680
                $dirs[] = $child;
4✔
681
            }
682
        }
683

684
        return $dirs;
4✔
685
    }
686

687
    /**
688
     * Retrieves an array of files present in the directory.
689
     *
690
     * @return array An array of files present in the directory.
691
     * @throws FileNotFoundException If the directory specified in the path does not exist.
692
     *  TODO: manual test
693
     */
694
    public function files(): array
695
    {
696
        if (!$this->isDir()) {
8✔
697
            throw new FileNotFoundException("Directory does not exist: " . $this->path);
4✔
698
        }
699

700
        $files = [];
4✔
701

702
        foreach ($this->builtin->scandir($this->path) as $filename) {
4✔
703
            if ($filename === '.' || $filename === '..') {
4✔
704
                continue;
4✔
705
            }
706

707
            $child = $this->append($filename);
4✔
708

709
            if ($child->isFile()) {
4✔
710
                $files[] = $child;
4✔
711
            }
712
        }
713

714
        return $files;
4✔
715
    }
716

717
    /**
718
     * Performs a pattern matching using the `fnmatch()` function.
719
     *
720
     * @param string $pattern The pattern to match against.
721
     * @return bool True if the path matches the pattern, false otherwise.
722
     *  TODO: manual test
723
     */
724
    public function fnmatch(string $pattern): bool
725
    {
726
        return $this->builtin->fnmatch($pattern, $this->path);
4✔
727
    }
728

729
    /**
730
     * Retrieves the content of a file.
731
     *
732
     * @return string The content of the file as a string.
733
     * @throws FileNotFoundException|IOException
734
     *  TODO: manual test
735
     */
736
    public function getContent(): string
737
    {
738
        if (!$this->isFile()) {
12✔
739
            throw new FileNotFoundException("File does not exist : " . $this->path);
4✔
740
        }
741

742
        $text = $this->builtin->file_get_contents($this->path);
8✔
743

744
        if ($text === false) {
8✔
745
            throw new IOException("An error occurred while reading file {$this->path}");
4✔
746
        }
747
        return $text;
4✔
748
    }
749

750
    /**
751
     * > Alias for getContent()
752
     * @return string
753
     * @throws FileNotFoundException
754
     * @throws IOException
755
     *  TODO: manual test
756
     */
757
    public function readText(): string
758
    {
759
        return $this->getContent();
4✔
760
    }
761

762
    /**
763
     * @throws IOException
764
     * @throws FileNotFoundException
765
     *  TODO: manual test
766
     */
767
    public function lines(): array
768
    {
769
        return explode(PHP_EOL, $this->getContent());
4✔
770
    }
771

772
    /**
773
     * Writes contents to a file.
774
     *
775
     * @param string $content The contents to be written to the file.
776
     * @param bool $append Append the content to the file's content instead of replacing it
777
     * @param bool $create Creates the file if it does not already exist
778
     * @return int The number of bytes that were written to the file
779
     * @throws FileNotFoundException
780
     * @throws IOException
781
     *  TODO: manual test
782
     */
783
    public function putContent(string $content, bool $append = false, bool $create = true): int
784
    {
785
        if (!$this->isFile() && !$create) {
16✔
786
            throw new FileNotFoundException("File does not exist : " . $this->path);
4✔
787
        }
788

789
        $result = $this->builtin->file_put_contents(
12✔
790
            $this->path,
12✔
791
            $content,
12✔
792
            $append ? FILE_APPEND : 0
12✔
793
        );
12✔
794

795
        if ($result === False) {
12✔
796
            throw new IOException("Error while putting content into $this->path");
4✔
797
        }
798

799
        return $result;
8✔
800
    }
801

802
    /**
803
     * Writes an array of lines to a file.
804
     *
805
     * @param array<string> $lines An array of lines to be written to the file.
806
     * @return int The number of bytes written to the file.
807
     * @throws FileNotFoundException
808
     * @throws IOException
809
     *  TODO: manual test
810
     */
811
    public function putLines(array $lines): int
812
    {
813
        return $this->putContent(
4✔
814
            implode(PHP_EOL, $lines)
4✔
815
        );
4✔
816
    }
817

818
    /**
819
     * Retrieves the permissions of a file or directory.
820
     *
821
     * @return int The permissions of the file or directory in octal notation.
822
     * @throws FileNotFoundException
823
     * @throws IOException
824
     *  TODO: manual test
825
     */
826
    public function getPermissions(): int
827
    {
828
        if (!$this->exists()) {
12✔
829
            throw new FileNotFoundException("File or dir does not exist : " . $this->path);
4✔
830
        }
831

832
        $perms = $this->builtin->fileperms($this->path);
8✔
833

834
        if ($perms === false) {
8✔
835
            throw new IOException("Error while getting permissions on " . $this->path);
4✔
836
        }
837

838
        return (int)substr(sprintf('%o', $perms), -4);
4✔
839
    }
840

841
    /**
842
     * Changes the permissions of a file or directory.
843
     *
844
     * @param int $permissions The new permissions to set. The value should be an octal number.
845
     * @param bool $clearStatCache
846
     * @throws FileNotFoundException
847
     * @throws IOException
848
     *  TODO: manual test
849
     */
850
    public function setPermissions(int $permissions, bool $clearStatCache = false): void
851
    {
852
        if (!$this->exists()) {
12✔
853
            throw new FileNotFoundException("File or dir does not exist : " . $this->path);
4✔
854
        }
855

856
        if ($clearStatCache) {
8✔
857
            $this->builtin->clearstatcache();
8✔
858
        }
859

860
        $success = $this->builtin->chmod($this->path, $permissions);
8✔
861

862
        if ($success === false) {
8✔
863
            throw new IOException("Error while setting permissions on " . $this->path);
4✔
864
        }
865
    }
866

867
    /**
868
     * Changes ownership of the file.
869
     *
870
     * @param string $user The new owner username.
871
     * @param string $group The new owner group name.
872
     * @throws FileNotFoundException
873
     * @throws IOException
874
     *  TODO: manual test
875
     */
876
    public function setOwner(string $user, string $group, bool $clearStatCache = false): void
877
    {
878
        if (!$this->exists()) {
20✔
879
            throw new FileNotFoundException("File or dir does not exist : " . $this->path);
4✔
880
        }
881

882
        if ($clearStatCache) {
16✔
883
            $this->builtin->clearstatcache();
4✔
884
        }
885

886
        $success =
16✔
887
            $this->builtin->chown($this->path, $user) &&
16✔
888
            $this->builtin->chgrp($this->path, $group);
16✔
889

890
        if ($success === false) {
16✔
891
            throw new IOException("Error while setting owner of " . $this->path);
8✔
892
        }
893
    }
894

895
    /**
896
     * Checks if a file exists.
897
     *
898
     * @return bool Returns true if the file exists, false otherwise.
899
     *  TODO: manual test
900
     */
901
    public function exists(): bool
902
    {
903
        return $this->builtin->file_exists($this->path);
4✔
904
    }
905

906
    /**
907
     * Return True if both pathname arguments refer to the same file or directory.
908
     *
909
     * @throws IOException
910
     *  TODO: manual test
911
     */
912
    public function sameFile(string | self $other): bool
913
    {
914
        return $this->absPath()->path() === $this->cast($other)->absPath()->path();
8✔
915
    }
916

917
    /**
918
     * Expands the path by performing three operations: expanding user, expanding variables, and normalizing the path.
919
     *
920
     * @return Path The expanded path.
921
     *  TODO: manual test
922
     */
923
    public function expand(): Path
924
    {
925
        return $this->expandUser()->expandVars()->normPath();
4✔
926
    }
927

928
    /**
929
     * Expands the user directory in the file path.
930
     *
931
     * @return self The modified instance with the expanded user path.
932
     *  TODO: manual test
933
     */
934
    public function expandUser(): self
935
    {
936
        $path = $this->path();
4✔
937
        if (str_starts_with($path, '~')) {
4✔
938
            $home = $_SERVER['HOME'];
4✔
939
            $path = self::join($home, substr($path, 1));
4✔
940
        }
941
        return $this->cast($path);
4✔
942
    }
943

944
    /**
945
     * Expands variables in the path.
946
     *
947
     * Searches for variable placeholders in the path and replaces them with their corresponding values from the environment variables.
948
     *
949
     * @return Path The path with expanded variables.
950
     *  TODO: manual test
951
     */
952
    public function expandVars(): Path
953
    {
954
        $path = preg_replace_callback(
8✔
955
            '/\$\{([^}]+)}|\$(\w+)/',
8✔
956
            function($matches) {
8✔
957
                return $this->builtin->getenv($matches[1] ?: $matches[2]);
8✔
958
            },
8✔
959
            $this->path()
8✔
960
        );
8✔
961

962
        return $this->cast($path);
8✔
963
    }
964

965
    /**
966
     * Retrieves a list of files and directories that match a specified pattern.
967
     *
968
     * @param string $pattern The pattern to search for.
969
     * @return array A list of files and directories that match the pattern.
970
     * @throws FileNotFoundException
971
     * @throws IOException
972
     *  TODO: manual test
973
     */
974
    public function glob(string $pattern): array
975
    {
976
        if (!$this->isDir()) {
12✔
977
            throw new FileNotFoundException("Dir does not exist : " . $this->path);
4✔
978
        }
979

980
        $pattern = $this->append($pattern);
8✔
981

982
        $result = $this->builtin->glob($pattern->path());
8✔
983

984
        if ($result === false) {
8✔
985
            throw new IOException("Error while getting glob on " . $this->path);
4✔
986
        }
987

988
        return array_map(
4✔
989
            function (string $s) { return $this->append($s); },
4✔
990
            $result
4✔
991
        );
4✔
992
    }
993

994
    /**
995
     * Removes the file.
996
     *
997
     * @return void
998
     * @throws IOException if there was an error while removing the file.
999
     *
1000
     * @throws IOException|FileNotFoundException if the file does not exist or is not a file.
1001
     *  TODO: manual test
1002
     */
1003
    public function remove(): void
1004
    {
1005
        if (!$this->isFile()) {
12✔
1006
            throw new FileNotFoundException( $this->path . " is not a file");
4✔
1007
        }
1008
        $result = $this->builtin->unlink($this->path);
8✔
1009
        if (!$result) {
8✔
1010
            throw new IOException( "Error while removing the file " . $this->path);
4✔
1011
        }
1012
    }
1013

1014
    /**
1015
     * > Alias for Path->remove()
1016
     * @return void
1017
     * @throws IOException|FileNotFoundException
1018
     *  TODO: manual test
1019
     */
1020
    public function unlink(): void
1021
    {
1022
        $this->remove();
4✔
1023
    }
1024

1025
    /**
1026
     * Like remove(), but do not throw an exception if the file does not exist
1027
     *
1028
     * @return void
1029
     * @throws IOException
1030
     * @throws FileNotFoundException
1031
     *  TODO: manual test
1032
     */
1033
    public function remove_p(): void
1034
    {
1035
        if (!$this->exists()) {
8✔
1036
            return;
4✔
1037
        }
1038
        $this->remove();
4✔
1039
    }
1040

1041
    /**
1042
     * Removes a directory and its contents.
1043
     *
1044
     * @throws FileNotFoundException
1045
     * @throws IOException
1046
     *  TODO: manual test
1047
     */
1048
    public function rmdir(bool $recursive = false, bool $permissive = false): void
1049
    {
1050
        if (!$this->isDir()) {
24✔
1051
            if ($permissive) {
8✔
1052
                return;
4✔
1053
            }
1054
            throw new FileNotFoundException("{$this->path} is not a directory");
4✔
1055
        }
1056

1057
        $subDirs = $this->dirs();
16✔
1058
        $files = $this->files();
16✔
1059

1060
        if ((!empty($subdirs) || !empty($files)) && !$recursive) {
16✔
1061
            throw new IOException("Directory is not empty : " . $this->path);
4✔
1062
        }
1063

1064
        foreach ($subDirs as $dir) {
12✔
1065
            $dir->rmdir(true, false);
4✔
1066
        }
1067

1068
        foreach ($files as $file) {
12✔
1069
            $file->remove();
4✔
1070
        }
1071

1072
        $result = $this->builtin->rmdir($this->path());
12✔
1073

1074
        if ($result === false) {
12✔
1075
            throw new IOException("Error while removing directory : " . $this->path);
4✔
1076
        }
1077
    }
1078

1079
    /**
1080
     * @throws FileNotFoundException
1081
     * @throws IOException
1082
     *  TODO: manual test
1083
     */
1084
    public function rename(string|self $newPath): Path
1085
    {
1086
        return $this->move($newPath);
4✔
1087
    }
1088

1089
    /**
1090
     * @throws IOException
1091
     *  TODO: manual test
1092
     */
1093
    public function readHash(string $algo, bool $binary = false): string
1094
    {
1095
        $result = $this->builtin->hash_file($algo, $this->path, $binary);
12✔
1096
        if ($result === false) {
12✔
1097
            throw new IOException("Error while computing the hash of " . $this->path);
8✔
1098
        }
1099
        return $result;
4✔
1100
    }
1101

1102
    /**
1103
     * Reads the target of a symbolic link and returns a new instance of the current class.
1104
     *
1105
     * @return self The target of the symbolic link as a new instance of the current class.
1106
     * @throws FileNotFoundException If the path does not exist or is not a symbolic link.
1107
     * @throws IOException If there is an error while getting the target of the symbolic link.
1108
     *  TODO: manual test
1109
     */
1110
    public function readLink(): self
1111
    {
1112
        if (!$this->isLink()) {
12✔
1113
            throw new FileNotFoundException($this->path() . " does not exist or is not a symbolic link");
4✔
1114
        }
1115
        $result = $this->builtin->readLink($this->path);
8✔
1116
        if ($result === false) {
8✔
1117
            throw new IOException("Error while getting the target of the symbolic link " . $this->path);
4✔
1118
        }
1119
        return $this->cast($result);
4✔
1120
    }
1121

1122
    /**
1123
     * Opens a file in the specified mode.
1124
     *
1125
     * @param string $mode The mode in which to open the file. Defaults to 'r'.
1126
     * @return resource|false Returns a file pointer resource on success, or false on failure.
1127
     * @throws FileNotFoundException If the path does not refer to a file.
1128
     * @throws IOException If the file fails to open.
1129
     *  TODO: manual test
1130
     */
1131
    public function open(string $mode = 'r'): mixed
1132
    {
1133
        if (!$this->isFile()) {
16✔
1134
            throw new FileNotFoundException($this->path . " is not a file");
4✔
1135
        }
1136

1137
        $handle = $this->builtin->fopen($this->path, $mode);
12✔
1138
        if ($handle === false) {
12✔
1139
            throw new IOException("Failed opening file " . $this->path);
4✔
1140
        }
1141

1142
        return $handle;
8✔
1143
    }
1144

1145
    /**
1146
     * @throws FileNotFoundException
1147
     *  TODO: manual test
1148
     */
1149
    public function walkDirs(): \Iterator
1150
    {
1151
        if (!$this->isDir()) {
8✔
1152
            throw new FileNotFoundException("Directory does not exist: " . $this->path);
4✔
1153
        }
1154

1155
        $iterator = $this->builtin->getRecursiveIterator($this->path);
4✔
1156

1157
        foreach ($iterator as $file) {
4✔
1158
            yield $this->cast($file);
4✔
1159
        }
1160
    }
1161

1162
    /**
1163
     * Calls a callback with a file handle opened with the specified mode and closes the handle afterward.
1164
     *
1165
     * @param callable $callback The callback function to be called with the file handle.
1166
     * @param string $mode The mode in which to open the file. Defaults to 'r'.
1167
     * @throws Throwable If an exception is thrown within the callback function.
1168
     *  TODO: manual test
1169
     */
1170
    public function with(callable $callback, string $mode = 'r'): mixed
1171
    {
1172
        $handle = $this->open($mode);
16✔
1173
        try {
1174
            return $callback($handle);
16✔
1175
        } finally {
1176
            $closed = $this->builtin->fclose($handle);
16✔
1177
            if (!$closed) {
16✔
1178
                throw new IOException("Could not close the file stream : " . $this->path);
7✔
1179
            }
1180
        }
1181
    }
1182

1183
    /**
1184
     * Retrieves chunks of data from the file.
1185
     *
1186
     * @param int $chunk_size The size of each chunk in bytes. Defaults to 8192.
1187
     * @return Generator Returns a generator that yields each chunk of data read from the file.
1188
     * @throws FileNotFoundException
1189
     * @throws IOException
1190
     * @throws Throwable
1191
     *  TODO: manual test
1192
     */
1193
    public function chunks(int $chunk_size = 8192): Generator
1194
    {
1195
        $handle = $this->open('rb');
12✔
1196
        try {
1197
            while (!$this->builtin->feof($handle)) {
12✔
1198
                yield $this->builtin->fread($handle, $chunk_size);
12✔
1199
            }
1200
        } finally {
1201
            $closed = $this->builtin->fclose($handle);
12✔
1202
            if (!$closed) {
12✔
1203
                throw new IOException("Could not close the file stream : " . $this->path);
6✔
1204
            }
1205
        }
1206
    }
1207

1208
    /**
1209
     * Check whether this path is absolute.
1210
     *
1211
     * @return bool
1212
     *  TODO: manual test
1213
     */
1214
    public function isAbs(): bool
1215
    {
1216
        return str_starts_with($this->path, '/');
8✔
1217
    }
1218

1219
    /**
1220
     * > Alias for Path->setPermissions() method
1221
     * Changes permissions of the file.
1222
     *
1223
     * @param int $mode The new permissions (octal).
1224
     * @throws FileNotFoundException|IOException
1225
     *  TODO: manual test
1226
     */
1227
    public function chmod(int $mode): void
1228
    {
1229
        $this->setPermissions($mode);
4✔
1230
    }
1231

1232
    /**
1233
     * > Alias for Path->setOwner() method
1234
     * Changes ownership of the file.
1235
     *
1236
     * @param string $user The new owner username.
1237
     * @param string $group The new owner group name.
1238
     * @throws FileNotFoundException|IOException
1239
     *  TODO: manual test
1240
     */
1241
    public function chown(string $user, string $group): void
1242
    {
1243
        $this->setOwner($user, $group);
4✔
1244
    }
1245

1246
    /**
1247
     * Changes the root directory of the current process to the specified directory.
1248
     *
1249
     * @throws IOException
1250
     * @throws FileNotFoundException
1251
     *  TODO: manual test
1252
     */
1253
    public function chroot(): void
1254
    {
1255
        if (!$this->isDir()) {
12✔
1256
            throw new FileNotFoundException("Dir does not exist : " . $this->path);
4✔
1257
        }
1258

1259
        $success = $this->builtin->chroot($this->path);
8✔
1260
        if (!$success) {
8✔
1261
            throw new IOException("Error changing root directory to " . $this->path);
4✔
1262
        }
1263
    }
1264

1265
    /**
1266
     * Checks if the file is a symbolic link.
1267
     *
1268
     * @return bool
1269
     *  TODO: manual test
1270
     */
1271
    public function isLink(): bool
1272
    {
1273
        return $this->builtin->is_link($this->path);
8✔
1274
    }
1275

1276
    /**
1277
     * Checks if the path is a mount point.
1278
     *
1279
     * @return bool True if the path is a mount point, false otherwise.
1280
     *  TODO: manual test
1281
     */
1282
    public function isMount(): bool
1283
    {
1284
        return $this->builtin->disk_free_space($this->path) !== false;
8✔
1285
    }
1286

1287
    /**
1288
     * Create a hard link pointing to this path.
1289
     *
1290
     * @param string|Path $newLink
1291
     * @return Path
1292
     * @throws FileExistsException
1293
     * @throws FileNotFoundException
1294
     * @throws IOException
1295
     *  TODO: manual test
1296
     */
1297
    public function link(string|self $newLink): self
1298
    {
1299
        if (!$this->exists()) {
20✔
1300
            throw new FileNotFoundException("File or dir does not exist : " . $this);
4✔
1301
        }
1302

1303
        $newLink = $this->cast($newLink);
16✔
1304

1305
        if ($newLink->exists()) {
16✔
1306
            throw new FileExistsException($newLink . " already exist");
4✔
1307
        }
1308

1309
        $success = $this->builtin->link($this->path, (string)$newLink);
12✔
1310

1311
        if ($success === false) {
12✔
1312
            throw new IOException("Error while creating the link from " . $this->path . " to " . $newLink);
4✔
1313
        }
1314

1315
        return $newLink;
8✔
1316
    }
1317

1318
    /**
1319
     * Like stat(), but do not follow symbolic links.
1320
     *
1321
     * @return array
1322
     * @throws IOException
1323
     *  TODO: manual test
1324
     */
1325
    public function lstat(): array
1326
    {
1327
        $result = $this->builtin->lstat($this->path);
8✔
1328
        if ($result === false) {
8✔
1329
            throw new IOException("Error while getting lstat of " . $this->path);
4✔
1330
        }
1331
        return $result;
4✔
1332
    }
1333

1334
    /**
1335
     * Creates a symbolic link to the specified destination.
1336
     *
1337
     * @param string|self $newLink The path or the instance of the symbolic link to create.
1338
     * @return self The instance of the symbolic link that was created.
1339
     * @throws FileNotFoundException If the file or directory does not exist.
1340
     * @throws FileExistsException If the symbolic link already exists.
1341
     * @throws IOException If there was an error while creating the symbolic link.
1342
     *  TODO: manual test
1343
     */
1344
    public function symlink(string | self $newLink): self
1345
    {
1346
        if (!$this->exists()) {
16✔
1347
            throw new FileNotFoundException("File or dir does not exist : " . $this);
4✔
1348
        }
1349

1350
        $newLink = $this->cast($newLink);
12✔
1351

1352
        if ($newLink->exists()) {
12✔
1353
            throw new FileExistsException($newLink . " already exist");
4✔
1354
        }
1355

1356
        $success = $this->builtin->symlink($this->path, (string)$newLink);
8✔
1357

1358
        if ($success === false) {
8✔
1359
            throw new IOException("Error while creating the symbolic link from " . $this->path . " to " . $newLink);
4✔
1360
        }
1361

1362
        return $newLink;
4✔
1363
    }
1364

1365
    /**
1366
     * Returns the individual parts of this path.
1367
     * The eventual leading directory separator is kept.
1368
     *
1369
     * Ex:
1370
     *
1371
     *     Path('/foo/bar/baz').parts()
1372
     *     >>> '/', 'foo', 'bar', 'baz'
1373
     *
1374
     * @return array
1375
     *  TODO: manual test
1376
     */
1377
    public function parts(): array
1378
    {
1379
        $parts = [];
8✔
1380
        if (str_starts_with($this->path, DIRECTORY_SEPARATOR)) {
8✔
1381
            $parts[] = DIRECTORY_SEPARATOR;
4✔
1382
        }
1383
        $parts += explode(DIRECTORY_SEPARATOR, $this->path);
8✔
1384
        return $parts;
8✔
1385
    }
1386

1387
    /**
1388
     * Compute a version of this path that is relative to another path.
1389
     *
1390
     * @param string|Path $basePath
1391
     * @return string
1392
     * @throws FileNotFoundException
1393
     * @throws IOException
1394
     *  TODO: manual test
1395
     */
1396
    public function getRelativePath(string|self $basePath): string
1397
    {
1398
        if (!$this->exists()) {
12✔
1399
            throw new FileNotFoundException("{$this->path} is not a file or directory");
4✔
1400
        }
1401

1402
        $path = (string)$this->absPath();
8✔
1403
        $basePath = (string)$basePath;
8✔
1404

1405
        $realBasePath = $this->builtin->realpath($basePath);
8✔
1406
        if ($realBasePath === false) {
8✔
1407
            throw new FileNotFoundException("$basePath does not exist or unable to get a real path");
4✔
1408
        }
1409

1410
        $pathParts = explode(DIRECTORY_SEPARATOR, $path);
4✔
1411
        $baseParts = explode(DIRECTORY_SEPARATOR, $realBasePath);
4✔
1412

1413
        while (count($pathParts) && count($baseParts) && ($pathParts[0] == $baseParts[0])) {
4✔
1414
            array_shift($pathParts);
4✔
1415
            array_shift($baseParts);
4✔
1416
        }
1417

1418
        return str_repeat('..' . DIRECTORY_SEPARATOR, count($baseParts)) . implode(DIRECTORY_SEPARATOR, $pathParts);
4✔
1419
    }
1420
}
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