mimmi20/composer-plugin-symlink

View on GitHub
src/AssetsInstaller.php

Summary

Maintainability
A
0 mins
Test Coverage
B
82%
<?php

/**
 * This file is part of the mimmi20/composer-plugin-symlink package.
 *
 * Copyright (c) 2023-2024, Thomas Mueller <mimmi20@live.de>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

declare(strict_types = 1);

namespace Mimmi20\CopyPlugin;

use Composer\Composer;
use Composer\Config;
use Composer\IO\IOInterface;
use Composer\Util\Filesystem;
use Composer\Util\Platform;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
use SplFileInfo;
use Symfony\Component\Filesystem\Exception\IOException;
use UnexpectedValueException;

use function assert;
use function dirname;
use function file_exists;
use function fopen;
use function in_array;
use function is_array;
use function is_dir;
use function is_int;
use function is_link;
use function mkdir;
use function rmdir;
use function rtrim;
use function scandir;
use function sprintf;
use function stream_copy_to_stream;
use function stream_is_local;
use function unlink;

final readonly class AssetsInstaller
{
    /** @api */
    public const string VENDOR_DIR_KEY = 'vendor-dir';

    /** @api */
    public const string ASSETS_STRATEGY = 'assets-strategy';

    /** @api */
    public const string ASSETS_FILES = 'assets-files';

    /** @api */
    public const string STRATEGY_AUTO = 'auto';

    /** @api */
    public const string STRATEGY_COPY = 'copy';

    /** @api */
    public const string STRATEGY_SYMLINK = 'symlink';

    /** @throws void */
    public function __construct(private Composer $composer, private IOInterface $io, private Filesystem $filesystem)
    {
        // nothing to do
    }

    /** @throws RuntimeException */
    public function process(): void
    {
        $composer = $this->composer;
        $config   = $composer->getConfig();

        $strategy    = $this->getInstallStrategy($config);
        $assetsFiles = $this->getAssetsFiles($config);

        if (empty($assetsFiles)) {
            return;
        }

        $this->io->write('<fg=red>copy files/create symlinks</>');

        $this->processFiles($config, $assetsFiles, $strategy);
    }

    /**
     * @return array<string>
     * @phpstan-return array<array-key, string>
     *
     * @throws RuntimeException
     */
    private function getAssetsFiles(Config $config): array
    {
        $assetsFiles = $config->get(self::ASSETS_FILES);

        if (!is_array($assetsFiles) && $assetsFiles !== null) {
            $this->io->writeError(
                sprintf('<fg=red>Config option \'%s\' is invalid.</>', self::ASSETS_FILES),
            );

            return [];
        }

        if (empty($assetsFiles)) {
            return [];
        }

        $results = [];

        foreach ($assetsFiles as $vendorPath => $publicPath) {
            if (is_int($vendorPath)) {
                $this->io->writeError(
                    sprintf('<fg=red>File/Directory \'%s\' is invalid.</>', $vendorPath),
                );

                continue;
            }

            foreach ((array) $publicPath as $singlePath) {
                if (is_int($singlePath)) {
                    $this->io->writeError(
                        sprintf('<fg=red>File/Directory \'%s\' is invalid.</>', $singlePath),
                    );

                    continue;
                }

                if (isset($results[$singlePath])) {
                    $this->io->writeError(
                        sprintf(
                            '<fg=red>File/Directory \'%s\' is already used for file %s.</>',
                            $singlePath,
                            $results[$singlePath],
                        ),
                    );

                    continue;
                }

                $results[$singlePath] = $vendorPath;
            }
        }

        return $results;
    }

    /** @throws RuntimeException */
    private function getInstallStrategy(Config $config): string
    {
        $strategy = $config->get(self::ASSETS_STRATEGY);

        if ($strategy === null) {
            $strategy = self::STRATEGY_AUTO;
        }

        if ($strategy === self::STRATEGY_AUTO) {
            $strategy = Platform::isWindows() ? self::STRATEGY_COPY : self::STRATEGY_SYMLINK;
        }

        if (!in_array($strategy, [self::STRATEGY_SYMLINK, self::STRATEGY_COPY], true)) {
            throw new RuntimeException(sprintf('unknown Copy Strategy \'%s\'', $strategy));
        }

        return $strategy;
    }

    /**
     * @param array<string> $files
     *
     * @throws RuntimeException
     */
    private function processFiles(Config $config, array $files, string $strategy): void
    {
        $vendorDir = $config->get(self::VENDOR_DIR_KEY);

        foreach ($files as $target => $source) {
            if (is_int($target)) {
                $this->io->writeError(sprintf('<fg=red>invalides target \'%s\'!.</>', $target));

                continue;
            }

            if (!file_exists($source)) {
                $this->io->writeError(sprintf('<fg=red>File \'%s\' not found.</>', $source));

                continue;
            }

            $sourcePath = $source;
            $targetPath = $target;

            if (!$this->filesystem->isAbsolutePath($sourcePath)) {
                $sourcePath = $vendorDir . '/../' . $sourcePath;
            }

            if (!$this->filesystem->isAbsolutePath($targetPath)) {
                $targetPath = $vendorDir . '/../' . $targetPath;
            }

            $sourcePath = $this->filesystem->normalizePath($sourcePath);
            $targetPath = $this->filesystem->normalizePath($targetPath);

            // is_link works on broken symlinks too
            if (file_exists($targetPath) || is_link($targetPath)) {
                $message = '<fg=red>- delete ';

                if (is_dir($sourcePath)) {
                    $message .= 'Directory</>';
                } else {
                    $message .= 'File</>';
                }

                $message .= ' ' . $target;

                $this->io->write($message);

                $this->deleteDir($targetPath, 1);

                if (is_dir($targetPath) && !is_link($targetPath)) {
                    rmdir($targetPath);
                } else {
                    unlink($targetPath);
                }
            }

            $message = '<fg=yellow>- ';

            if ($strategy === self::STRATEGY_COPY) {
                $message .= 'copy';
            } else {
                $message .= 'create Symlink for';
            }

            if (is_dir($sourcePath)) {
                $message .= ' Directory</>';
            } else {
                $message .= ' File</>';
            }

            if ($strategy === self::STRATEGY_COPY) {
                $message .= ' from ';

                if (is_dir($sourcePath)) {
                    $message .= rtrim($source, '/') . '/';
                } else {
                    $message .= $source;
                }

                $message .= ' <fg=yellow>to</> ' . $target;
            } else {
                $message .= ' ' . $target;

                $message .= ' <fg=yellow>to</> ';

                if (is_dir($sourcePath)) {
                    $message .= rtrim($source, '/') . '/';
                } else {
                    $message .= $source;
                }
            }

            $this->io->write($message);

            if ($strategy === self::STRATEGY_COPY) {
                $this->copy($sourcePath, $targetPath);
            } else {
                $this->createDirectory(dirname($targetPath));
                $this->filesystem->relativeSymlink($sourcePath, $targetPath);
            }
        }
    }

    /** @throws void */
    private function createDirectory(string $directory): void
    {
        if (file_exists($directory)) {
            return;
        }

        @mkdir($directory, 0775, true);
    }

    /**
     * Copies a file or directory.
     *
     * @throws IOException
     * @throws UnexpectedValueException
     */
    private function copy(string $source, string $dest): void
    {
        if (stream_is_local($source) && !file_exists($source)) {
            throw new IOException(sprintf('File or directory \'%s\' not found.', $source));
        }

        if (is_dir($source)) {
            $this->createDirectory($dest);

            /** @var FilesystemIterator<SplFileInfo> $removeIterator */
            $removeIterator = new FilesystemIterator($dest);

            foreach ($removeIterator as $item) {
                $this->filesystem->remove($item->getPathname());
            }

            /** @var RecursiveIteratorIterator<RecursiveDirectoryIterator> $iterator */
            $iterator = new RecursiveIteratorIterator(
                new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS),
                RecursiveIteratorIterator::SELF_FIRST,
            );

            foreach ($iterator as $item) {
                assert($item instanceof SplFileInfo);

                if ($item->isDir()) {
                    $this->createDirectory($dest . '/' . $iterator->getSubPathname());
                } else {
                    $this->copy($item->getPathname(), $dest . '/' . $iterator->getSubPathname());
                }
            }

            return;
        }

        $this->createDirectory(dirname($dest));

        $sourceRes = fopen($source, 'r');

        if ($sourceRes === false) {
            throw new IOException(sprintf('Unable to to read file \'%s\'.', $source));
        }

        $destRes = fopen($dest, 'w');

        if ($destRes === false) {
            throw new IOException(sprintf('Unable to to write file \'%s\'.', $dest));
        }

        if (@stream_copy_to_stream($sourceRes, $destRes) === false) {
            // @ is escalated to exception
            throw new IOException(sprintf('Unable to copy file \'%s\' to \'%s\'.', $source, $dest));
        }
    }

    /** @throws void */
    private function deleteDir(string $baseDir, int $level): void
    {
        if (!is_dir($baseDir)) {
            return;
        }

        $dirList = scandir($baseDir);

        if ($dirList === false) {
            return;
        }

        foreach ($dirList as $currentDir) {
            $ignoreList = $level === 1
                ? ['.', '..', '.gitignore']
                : ['.', '..'];

            if (in_array($currentDir, $ignoreList, true)) {
                continue;
            }

            if (is_dir($baseDir . '/' . $currentDir)) {
                $this->deleteDir($baseDir . '/' . $currentDir, $level + 1);

                rmdir($baseDir . '/' . $currentDir);
            } else {
                unlink($baseDir . '/' . $currentDir);
            }
        }
    }
}