Path.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. <?php
  2. namespace Path;
  3. use InvalidArgumentException;
  4. use Path\Exception\FileExistsException;
  5. use Path\Exception\FileNotFoundException;
  6. use Path\Path\RecursiveDirectoryIterator;
  7. use Path\Path\RecursiveIteratorIterator;
  8. use function Path\Path\lchmod;
  9. /**
  10. * Represents a file or directory path.
  11. *
  12. * @package olinox14/path
  13. */
  14. class Path
  15. {
  16. /**
  17. * File exists
  18. */
  19. const F_OK = 0;
  20. /**
  21. * Has read permission on the file
  22. */
  23. const R_OK = 4;
  24. /**
  25. * Has write permission on the file
  26. */
  27. const W_OK = 2;
  28. /**
  29. * Has execute permission on the file
  30. */
  31. const X_OK = 1;
  32. protected string $path;
  33. /**
  34. * Joins two or more parts of a path together, inserting '/' as needed.
  35. * If any component is an absolute path, all previous path components
  36. * will be discarded. An empty last part will result in a path that
  37. * ends with a separator.
  38. *
  39. * TODO: see if necessary : https://github.com/python/cpython/blob/d22c066b802592932f9eb18434782299e80ca42e/Lib/posixpath.py#L81
  40. *
  41. * @param string|Path $path The base path
  42. * @param string ...$parts The parts of the path to be joined.
  43. * @return string The resulting path after joining the parts using the directory separator.
  44. */
  45. public static function join(string|self $path, string|self ...$parts): string
  46. {
  47. $path = (string)$path;
  48. $parts = array_map(fn($x) => (string)$x, $parts);
  49. foreach ($parts as $part) {
  50. if (str_starts_with($part, DIRECTORY_SEPARATOR)) {
  51. $path = $part;
  52. } elseif (!$path || str_ends_with($path, DIRECTORY_SEPARATOR)) {
  53. $path .= $part;
  54. } else {
  55. $path .= DIRECTORY_SEPARATOR . $part;
  56. }
  57. }
  58. return $path;
  59. }
  60. public function withFile(string|self $path, string $mode = 'r') {
  61. //TODO: do a 'with open' like method
  62. }
  63. /**
  64. * Copies a directory and its contents recursively from the source directory to the destination directory.
  65. *
  66. * @param string|self $src The source directory to be copied. It can be a string representing the directory path
  67. * or an instance of the same class.
  68. * @param string|self $dst The destination directory where the source directory and its contents will be copied.
  69. * It can be a string representing the directory path or an instance of the same class.
  70. *
  71. * @return void
  72. * TODO: see https://stackoverflow.com/a/12763962/4279120
  73. *
  74. * @throws FileNotFoundException
  75. * @throws FileExistsException
  76. */
  77. public static function copy_dir(string|self $src, string|self $dst): void
  78. {
  79. $src = (string)$src;
  80. $dst = (string)$dst;
  81. if (!is_dir($src)) {
  82. throw new FileNotFoundException("Directory does not exist : " . $src);
  83. }
  84. if (!is_dir($dst)) {
  85. throw new FileNotFoundException("Directory does not exist : " . $dst);
  86. }
  87. $newDir = self::join($dst, pathinfo($src, PATHINFO_FILENAME));
  88. if (file_exists($newDir)) {
  89. throw new FileExistsException("Directory already exists : " . $newDir);
  90. }
  91. self::_copy_dir($src, $dst);
  92. }
  93. /**
  94. * [internal] Recursively copies a directory from source to destination.
  95. *
  96. * @param string $src The path to the source directory.
  97. * @param string $dst The path to the destination directory.
  98. * @return void
  99. * @throws FileNotFoundException If a file within the source directory does not exist.
  100. */
  101. private static function _copy_dir(string $src, string $dst): void
  102. {
  103. $dir = opendir($src);
  104. if (!is_dir($dst)) {
  105. mkdir($dst);
  106. }
  107. try {
  108. while (($file = readdir($dir)) !== false) {
  109. if ($file === '.' || $file === '..') {
  110. continue;
  111. }
  112. $path = self::join($src, $file);
  113. $newPath = self::join($dst, $file);
  114. if (is_dir($path)) {
  115. self::_copy_dir($path, $newPath);
  116. } else if(is_file($path)) {
  117. copy($path, $newPath);
  118. } else {
  119. throw new FileNotFoundException("File does not exist : " . $path);
  120. }
  121. }
  122. } finally {
  123. closedir($dir);
  124. }
  125. }
  126. public function __construct(string $path)
  127. {
  128. $this->path = $path;
  129. return $this;
  130. }
  131. public function __toString(): string {
  132. return $this->path;
  133. }
  134. /**
  135. * Retrieves the current path of the file or directory
  136. *
  137. * @return string The path of the file or directory
  138. */
  139. public function path(): string
  140. {
  141. return $this->path;
  142. }
  143. /**
  144. * Checks if the given path is equal to the current path.
  145. *
  146. * @param string|Path $path The path to compare against.
  147. *
  148. * @return bool Returns true if the given path is equal to the current path, false otherwise.
  149. */
  150. public function eq(string|self $path): bool {
  151. return (string)$path === $this->path;
  152. }
  153. /**
  154. * Appends parts to the current path.
  155. *
  156. * @see Path::join()
  157. *
  158. * @param string ...$parts The parts to be appended to the current path.
  159. * @return self Returns an instance of the class with the appended path.
  160. */
  161. public function append(string ...$parts): self
  162. {
  163. $this->path = self::join($this->path, ...$parts);
  164. return $this;
  165. }
  166. /**
  167. * Returns an absolute version of the current path.
  168. *
  169. * @return string
  170. * TODO: make an alias `realpath`
  171. */
  172. public function abspath(): string
  173. {
  174. return realpath($this->path);
  175. }
  176. /**
  177. * Checks the access rights for a given file or directory.
  178. * From the python `os.access` method
  179. *
  180. * @param int $mode The access mode to check. Permitted values:
  181. * - F_OK: checks for the existence of the file or directory.
  182. * - R_OK: checks for read permission.
  183. * - W_OK: checks for write permission.
  184. * - X_OK: checks for execute permission.
  185. * @return bool Returns true if the permission check is successful; otherwise, returns false.
  186. * TODO: complete unit tests
  187. */
  188. function access(int $mode): bool
  189. {
  190. return match ($mode) {
  191. self::F_OK => file_exists($this->path),
  192. self::R_OK => is_readable($this->path),
  193. self::W_OK => is_writable($this->path),
  194. self::X_OK => is_executable($this->path),
  195. default => throw new \RuntimeException('Invalid mode'),
  196. };
  197. }
  198. /**
  199. * Retrieves the last access time of a file or directory.
  200. *
  201. * @return string|null The last access time of the file or directory in 'Y-m-d H:i:s' format. Returns null if the file or directory does not exist or on error.
  202. */
  203. function atime(): ?string
  204. {
  205. $time = fileatime($this->path);
  206. if ($time === false) {
  207. return null;
  208. }
  209. return date('Y-m-d H:i:s', $time);
  210. }
  211. /**
  212. * Check if the path refers to a regular file.
  213. *
  214. * @return bool Returns true if the path refers to a regular file, otherwise returns false.
  215. */
  216. public function isFile(): bool
  217. {
  218. return is_file($this->path);
  219. }
  220. /**
  221. * Check if the given path is a directory.
  222. *
  223. * @return bool Returns true if the path is a directory, false otherwise.
  224. */
  225. public function isDir(): bool
  226. {
  227. return is_dir($this->path);
  228. }
  229. /**
  230. * Get the extension of the given path.
  231. *
  232. * @return string Returns the extension of the path as a string if it exists, or an empty string otherwise.
  233. */
  234. public function ext(): string
  235. {
  236. return pathinfo($this->path, PATHINFO_EXTENSION);
  237. }
  238. /**
  239. * Get the base name of the path.
  240. *
  241. * @return string The base name of the path.
  242. */
  243. public function basename(): string
  244. {
  245. return pathinfo($this->path, PATHINFO_BASENAME);
  246. }
  247. /**
  248. * Get the name of the file or path.
  249. *
  250. * @return string Returns the name of the file without its extension
  251. */
  252. public function name(): string
  253. {
  254. return pathinfo($this->path, PATHINFO_FILENAME);
  255. }
  256. /**
  257. * Creates a new directory.
  258. *
  259. * @param int $mode (optional) The permissions for the new directory. Default is 0777.
  260. * @param bool $recursive (optional) Indicates whether to create parent directories if they do not exist. Default is false.
  261. *
  262. * @return void
  263. * @throws FileExistsException
  264. */
  265. public function mkdir(int $mode = 0777, bool $recursive = false): void
  266. {
  267. // TODO: may we make $mode the second arg, and mimic the mode of the parent if not provided?
  268. if ($this->isDir()) {
  269. if (!$recursive) {
  270. throw new FileExistsException("Directory already exists : " . $this);
  271. } else {
  272. return;
  273. }
  274. }
  275. if ($this->isFile()) {
  276. throw new FileExistsException("A file with this name already exists : " . $this);
  277. }
  278. mkdir($this->path, $mode, $recursive);
  279. }
  280. /**
  281. * Deletes a file or a directory.
  282. *
  283. * @return void
  284. * @throws FileNotFoundException
  285. */
  286. public function delete(): void
  287. {
  288. if ($this->isFile()) {
  289. unlink($this->path);
  290. } else if ($this->isDir()) {
  291. rmdir($this->path);
  292. } else {
  293. throw new FileNotFoundException("File does not exist : " . $this);
  294. }
  295. }
  296. /**
  297. * Copies a file or directory to the specified destination.
  298. *
  299. * @param string|self $destination The destination path or object to copy the file or directory to.
  300. * @throws FileNotFoundException If the source file or directory does not exist.
  301. * @throws FileExistsException
  302. */
  303. public function copy(string|self $destination): void
  304. {
  305. if ($this->isFile()) {
  306. $destination = (string)$destination;
  307. if (is_dir($destination)) {
  308. $destination = self::join($destination, $this->basename());
  309. }
  310. if (file_exists($destination)) {
  311. throw new FileExistsException("File or dir already exists : " . $destination);
  312. }
  313. copy($this->path, $destination);
  314. } else if ($this->isDir()) {
  315. self::copy_dir($this, $destination);
  316. } else {
  317. throw new FileNotFoundException("File or dir does not exist : " . $this);
  318. }
  319. }
  320. /**
  321. * Moves a file or directory to a new location.
  322. *
  323. * @param string|Path $destination The new location where the file or directory should be moved to.
  324. *
  325. * @return void
  326. * @throws FileExistsException
  327. */
  328. public function move(string|self $destination): void
  329. {
  330. $destination = (string)$destination;
  331. if (is_dir($destination)) {
  332. $destination = self::join($destination, $this->basename());
  333. }
  334. if (file_exists($destination)) {
  335. throw new FileExistsException("File or dir already exists : " . $destination);
  336. }
  337. rename($this->path, $destination);
  338. }
  339. /**
  340. * Updates the access and modification time of a file or creates a new empty file if it doesn't exist.
  341. *
  342. * @param int|null $time (optional) The access and modification time to set. Default is the current time.
  343. * @param int|null $atime (optional) The access time to set. Default is the value of $time.
  344. *
  345. * @return void
  346. */
  347. public function touch($time = null, $atime = null): void
  348. {
  349. if (!file_exists($this->path)) {
  350. touch($this->path, $time, $atime);
  351. }
  352. }
  353. /**
  354. * Returns the last modified timestamp of a file or directory.
  355. *
  356. * @return int|bool The last modified timestamp, or false if an error occurred.
  357. */
  358. public function lastModified(): bool|int
  359. {
  360. return filemtime($this->path);
  361. }
  362. /**
  363. * Calculates the size of a file.
  364. *
  365. * @return bool|int The size of the file in bytes. Returns false if the file does not exist or on failure.
  366. */
  367. public function size(): bool|int
  368. {
  369. return filesize($this->path);
  370. }
  371. /**
  372. * Retrieves the parent directory of a file or directory path.
  373. *
  374. * @return string The parent directory of the specified path.
  375. */
  376. public function parent(): string
  377. {
  378. return dirname($this->path);
  379. }
  380. /**
  381. * Retrieves the contents of a file.
  382. *
  383. * @return bool|string The contents of the file as a string. Returns false if the file does not exist or on failure.
  384. */
  385. public function getContents(): bool|string
  386. {
  387. return file_get_contents($this->path);
  388. }
  389. /**
  390. * Writes contents to a file.
  391. *
  392. * @param mixed $contents The contents to be written to the file.
  393. * @return void
  394. */
  395. public function putContents($contents): void
  396. {
  397. file_put_contents($this->path, $contents);
  398. }
  399. /**
  400. * Appends contents to a file.
  401. *
  402. * @param string $contents The contents to append to the file.
  403. *
  404. * @return void
  405. */
  406. public function appendContents($contents): void
  407. {
  408. file_put_contents($this->path, $contents, FILE_APPEND);
  409. }
  410. /**
  411. * Retrieves the permissions of a file or directory.
  412. *
  413. * @return string The permissions of the file or directory in octal notation. Returns an empty string if the file or directory does not exist.
  414. */
  415. public function getPermissions(): string
  416. {
  417. return substr(sprintf('%o', fileperms($this->path)), -4);
  418. }
  419. /**
  420. * Changes the permissions of a file or directory.
  421. *
  422. * @param int $permissions The new permissions to set. The value should be an octal number.
  423. * @return bool Returns true on success, false on failure.
  424. */
  425. public function changePermissions($permissions): bool
  426. {
  427. return chmod($this->path, $permissions);
  428. }
  429. /**
  430. * Checks if a file exists.
  431. *
  432. * @return bool Returns true if the file exists, false otherwise.
  433. */
  434. public function exists(): bool
  435. {
  436. return file_exists($this->path);
  437. }
  438. public static function glob(string $pattern)
  439. {
  440. foreach (glob($pattern) as $filename) {
  441. yield new static($filename);
  442. }
  443. }
  444. public function rmdir()
  445. {
  446. if (!is_dir($this->path)) {
  447. throw new \RuntimeException("{$this->path} is not a directory");
  448. }
  449. $it = new RecursiveDirectoryIterator($this->path, RecursiveDirectoryIterator::SKIP_DOTS);
  450. $files = new RecursiveIteratorIterator($it,
  451. RecursiveIteratorIterator::CHILD_FIRST);
  452. foreach ($files as $file) {
  453. if ($file->isDir()) {
  454. rmdir($file->getRealPath());
  455. } else {
  456. unlink($file->getRealPath());
  457. }
  458. }
  459. rmdir($this->path);
  460. }
  461. public function open(string $mode = 'r')
  462. {
  463. if (!$this->isFile()) {
  464. throw new \RuntimeException("{$this->path} is not a file");
  465. }
  466. $handle = fopen($this->path, $mode);
  467. if ($handle === false) {
  468. throw new \RuntimeException("Failed opening file {$this->path}");
  469. }
  470. return $handle;
  471. }
  472. /**
  473. * Returns this path as a URI.
  474. *
  475. * @return string
  476. */
  477. public function as_uri(): string
  478. {
  479. throw new \Exception("Method not implemented");
  480. }
  481. /**
  482. * Returns the group that owns the file.
  483. *
  484. * @return string
  485. */
  486. public function group(): string
  487. {
  488. throw new \Exception("Method not implemented");
  489. }
  490. /**
  491. * Check whether this path is absolute.
  492. *
  493. * @return bool
  494. */
  495. public function is_absolute(): bool
  496. {
  497. return substr($this->path, 0, 1) === '/';
  498. }
  499. /**
  500. * (Not supported in PHP). In Python, this would convert the path to POSIX style, but in PHP there's no equivalent.
  501. * Therefore throwing an exception.
  502. *
  503. * @throws \RuntimeException
  504. */
  505. public function as_posix(): void
  506. {
  507. throw new \RuntimeException("Method 'as_posix' not supported in PHP");
  508. }
  509. /**
  510. * Changes permissions of the file.
  511. *
  512. * @param int $mode The new permissions (octal).
  513. * @return bool
  514. */
  515. public function chmod(int $mode): bool
  516. {
  517. return chmod($this->path, $mode);
  518. }
  519. /**
  520. * Changes ownership of the file.
  521. *
  522. * @param string $user The new owner username.
  523. * @param string $group The new owner group name.
  524. * @return bool
  525. */
  526. public function chown(string $user, string $group): bool
  527. {
  528. return chown($this->path, $user) && chgrp($this->path, $group);
  529. }
  530. /**
  531. * Checks if file is a block special file.
  532. *
  533. * @return bool
  534. */
  535. public function is_block_device(): bool
  536. {
  537. return function_exists('posix_isatty') && is_file($this->path) && posix_isatty($this->path);
  538. }
  539. /**
  540. * Checks if file is a character special file.
  541. *
  542. * @return bool
  543. */
  544. public function is_char_device(): bool
  545. {
  546. return function_exists('filetype') && filetype($this->path) === 'char';
  547. }
  548. /**
  549. * Checks if file is a Named Pipe (FIFO) special file.
  550. *
  551. * @return bool
  552. */
  553. public function is_fifo(): bool
  554. {
  555. return function_exists('filetype') && filetype($this->path) === 'fifo';
  556. }
  557. /**
  558. * Checks if file is a socket.
  559. *
  560. * @return bool
  561. */
  562. public function is_socket(): bool
  563. {
  564. return function_exists('filetype') && 'socket' === filetype($this->path);
  565. }
  566. /**
  567. * Checks if the file is a symbolic link.
  568. *
  569. * @return bool
  570. */
  571. public function is_symlink(): bool
  572. {
  573. return is_link($this->path);
  574. }
  575. /**
  576. * Iterate over the files in this directory.
  577. *
  578. * @return \Generator
  579. * @throws \RuntimeException if the path is not a directory.
  580. */
  581. public function iterdir()
  582. {
  583. if (!$this->isDir()) {
  584. throw new \RuntimeException("{$this->path} is not a directory");
  585. }
  586. foreach (new \DirectoryIterator($this->path) as $fileInfo) {
  587. if ($fileInfo->isDot()) continue;
  588. yield $fileInfo->getFilename();
  589. }
  590. }
  591. /**
  592. * Change the mode of path to the numeric mode.
  593. * This method does not follow symbolic links.
  594. *
  595. * @param int $mode
  596. * @return bool
  597. */
  598. public function lchmod(int $mode): bool
  599. {
  600. if (!function_exists('lchmod')) {
  601. return false;
  602. }
  603. return lchmod($this->path, $mode);
  604. }
  605. /**
  606. * Change the owner and group id of path to the numeric uid and gid.
  607. * This method does not follow symbolic links.
  608. *
  609. * @param int $uid User id
  610. * @param int $gid Group id
  611. * @return bool
  612. */
  613. public function lchown(int $uid, int $gid): bool
  614. {
  615. if (!function_exists('lchown')) {
  616. return false;
  617. }
  618. return lchown($this->path, $uid) && lchgrp($this->path, $gid);
  619. }
  620. /**
  621. * Create a hard link pointing to a path.
  622. *
  623. * @param string $target
  624. * @return bool
  625. */
  626. public function link_to(string $target): bool
  627. {
  628. if (!function_exists('link')) {
  629. return false;
  630. }
  631. return link($this->path, $target);
  632. }
  633. /**
  634. * Like stat(), but do not follow symbolic links.
  635. *
  636. * @return array|false
  637. */
  638. public function lstat()
  639. {
  640. return lstat($this->path);
  641. }
  642. /**
  643. * Returns the individual parts of this path.
  644. *
  645. * @return array
  646. */
  647. public function parts(): array
  648. {
  649. $separator = DIRECTORY_SEPARATOR;
  650. return explode($separator, $this->path);
  651. }
  652. /**
  653. * Opens the file in bytes mode, reads it, and closes the file.
  654. *
  655. * @return string
  656. * @throws \RuntimeException
  657. */
  658. public function read_bytes(): string
  659. {
  660. $bytes = file_get_contents($this->path, FILE_BINARY);
  661. if ($bytes === false) {
  662. throw new \RuntimeException("Error reading file {$this->path}");
  663. }
  664. return $bytes;
  665. }
  666. /**
  667. * Open the file in text mode, read it, and close the file
  668. *
  669. * @return string
  670. * @throws \RuntimeException
  671. */
  672. public function read_text(): string
  673. {
  674. $text = file_get_contents($this->path);
  675. if ($text === false) {
  676. throw new \RuntimeException("Error reading file {$this->path}");
  677. }
  678. return $text;
  679. }
  680. /**
  681. * Compute a version of this path that is relative to another path.
  682. *
  683. * @return string
  684. * @throws \RuntimeException
  685. */
  686. public function relative_to(string $other_path): string
  687. {
  688. $path = $this->absolute();
  689. $other = realpath($other_path);
  690. if ($other === false) {
  691. throw new \RuntimeException("$other_path does not exist or unable to get a real path");
  692. }
  693. $path_parts = explode(DIRECTORY_SEPARATOR, $path);
  694. $other_parts = explode(DIRECTORY_SEPARATOR, $other);
  695. while (count($path_parts) && count($other_parts) && ($path_parts[0] == $other_parts[0])) {
  696. array_shift($path_parts);
  697. array_shift($other_parts);
  698. }
  699. return str_repeat('..' . DIRECTORY_SEPARATOR, count($other_parts)) . implode(DIRECTORY_SEPARATOR, $path_parts);
  700. }
  701. }