diff --git a/.gitignore b/.gitignore index 8d51008b..fc4a0f87 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ /my-app.* /var/ /vendor/ +/tests/Examples/fixtures/**/.castor.stub.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9136da..76dbaaf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,8 @@ * Add support for running Castor on Linux arm64 and distribute the binary `castor.linux-arm64.phar` automatically with the release * Add a bash installer to ease installation -* Add a option `ignoreValidationErrors` on `AsTask` attribute to ignore +* Add support for importing remote functions and tasks +* Add an option `ignoreValidationErrors` on `AsTask` attribute to ignore parameters & options validation errors * Add support for dynamic autocomplete task arguments/options * Add support for merging an application `box.json` config file used by diff --git a/bin/generate-tests.php b/bin/generate-tests.php index a37d9e3b..c40ddb55 100755 --- a/bin/generate-tests.php +++ b/bin/generate-tests.php @@ -75,10 +75,14 @@ 'log:info', 'log:with-context', 'parallel:sleep', + 'remote-import:remote-tasks', 'run:ls', 'run:run-parallel', + // Imported tasks + 'pyrech:hello-example', + 'pyrech:foobar', ]; -$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context']); +$optionFilterList = array_flip(['help', 'quiet', 'verbose', 'version', 'ansi', 'no-ansi', 'no-interaction', 'context', 'no-remote', 'update-remotes']); foreach ($applicationDescription['commands'] as $task) { if (in_array($task['name'], $taskFilterList, true)) { continue; @@ -119,7 +123,7 @@ ; foreach ($dirs as $dir) { $class = u($dir->getRelativePath())->camel()->title()->append('Test')->toString(); - add_test([], $class, '{{ base }}/tests/Examples/fixtures/broken/' . $dir->getRelativePath()); + add_test([], $class, '{{ base }}/tests/Examples/fixtures/broken/' . $dir->getRelativePath(), true); } add_test(['args:passthru', 'a', 'b', '--no', '--foo', 'bar', '-x'], 'ArgPassthruExpanded'); @@ -140,7 +144,7 @@ add_test(['list'], 'LayoutWithFolder', __DIR__ . '/../tests/Examples/fixtures/layout/with-folder'); add_test(['list'], 'LayoutWithOldFolder', __DIR__ . '/../tests/Examples/fixtures/layout/with-old-folder'); -function add_test(array $args, string $class, ?string $cwd = null) +function add_test(array $args, string $class, ?string $cwd = null, bool $needRemote = false) { $fp = fopen(__FILE__, 'r'); fseek($fp, __COMPILER_HALT_OFFSET__ + 1); @@ -152,6 +156,7 @@ function add_test(array $args, string $class, ?string $cwd = null) env: [ 'COLUMNS' => 1000, 'ENDPOINT' => $_SERVER['ENDPOINT'], + 'CASTOR_NO_REMOTE' => $needRemote ? 0 : 1, ], timeout: null, ); @@ -163,6 +168,7 @@ function add_test(array $args, string $class, ?string $cwd = null) '{{ args }}' => implode(', ', array_map(fn ($arg) => var_export($arg, true), $args)), '{{ exitCode }}' => $process->getExitCode(), '{{ cwd }}' => $cwd ? ', ' . var_export($cwd, true) : '', + '{{ needRemote }}' => $needRemote ? ', needRemote: true' : '', ]); file_put_contents(__DIR__ . '/../tests/Examples/Generated/' . $class . '.php', $code); @@ -185,7 +191,7 @@ class {{ class_name }} extends TaskTestCase // {{ task }} public function test(): void { - $process = $this->runTask([{{ args }}]{{ cwd }}); + $process = $this->runTask([{{ args }}]{{ cwd }}{{ needRemote }}); $this->assertSame({{ exitCode }}, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/composer.json b/composer.json index 9196fa27..fa6359fb 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,10 @@ "psr-4": { "Castor\\": "src/" }, - "files": ["src/functions.php"] + "files": [ + "src/functions.php", + "src/functions-internal.php" + ] }, "autoload-dev": { "psr-4": { diff --git a/doc/getting-started/basic-usage.md b/doc/getting-started/basic-usage.md index 85fa68b5..cc7fa300 100644 --- a/doc/getting-started/basic-usage.md +++ b/doc/getting-started/basic-usage.md @@ -80,6 +80,10 @@ use function Castor\import; import(__DIR__); ``` +> [!NOTE] +> You can also import functions from a remote resource. See the +> [related documentation](../going-further/extending-castor/remote-imports.md). + ## Overriding task name, namespace or description The `Castor\Attribute\AsTask` attribute takes three optional diff --git a/doc/going-further/extending-castor/remote-imports.md b/doc/going-further/extending-castor/remote-imports.md new file mode 100644 index 00000000..fd835fbd --- /dev/null +++ b/doc/going-further/extending-castor/remote-imports.md @@ -0,0 +1,142 @@ +# Import remote functions + +Castor can import functions from your filesystem but also from a remote resource. + +## Importing functions + +When importing functions from a remote resource, Castor will use Composer to +download the packages and store them in `.castor/vendor/`. + +To import functions, you need to use the same `import()` function used to import +your tasks, but this time with a different syntax for the `path` argument. + +The import syntax depends on the source of the packages. + +### From a Composer package (scheme `composer://`) + +This is the most common use case when the functions to import are defined in a +Composer package. You can directly import them by using the package name +prefixed with the `composer://` scheme: + +```php +use function Castor\import; + +import('composer://vendor-name/project-name'); +``` + +This will import all the tasks defined in the package. + +#### Specify the version + +You can define the version of the package to import by using the `version` +argument: + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', version: '^1.0'); +``` + +You can use any version constraint supported by Composer (like `*`, `dev-main`, +`^1.0`, etc.). See the [Composer documentation](https://getcomposer.org/doc/articles/versions.md#writing-version-constraints) +for more information. + +> [!TIP] +> The `version` argument is optional and will default to `*`. + +#### Import from a package not pushed to packagist.org + +In some cases, you may have a Composer package that is not pushed to +packagist.org (like a private package hosted on packagist.com or another package +registry). In such cases, you can import it by using the `vcs` argument to +specify the repository URL where the package is hosted: + +```php + +use function Castor\import; + +import('composer://vendor-name/project-name', vcs: 'https://github.com/organization/repository.git'); +``` + +### From a repository (scheme `package://`) + +If the functions you want to import are not available as a Composer package, you +can still import them by using a special configuration that Composer will +understand. This will now use the `package://` scheme. + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', source: [ + 'url' => 'https://github.com/organization/repository.git', + 'type' => 'git', // 'Any source type supported by Composer (git, svn, etc)' + 'reference' => 'main', // A commit id, a branch or a tag name +]); +``` + +> [!NOTE] +> The "vendor-name/project-name" name can be whatever you want, and will only be +> used internally by Castor and Composer to make the repository behave like a +> normal Composer package. + +> [!TIP] +> Rather than using the `package://` scheme, it may be simpler to create a +> standard `composer.json` to your repository and import your newly created +> package by using the `composer://` scheme and the `vcs` argument. + +## Import only a specific file + +No matter where does the package come from (Composer package, git repository, +etc.), you can restrict the file (or directory) to be imported. This is +configured by using the `file` argument specifying the path inside the package +or repository. + +```php +use function Castor\import; + +import('composer://vendor-name/project-name', file: 'castor/my-tasks.php'); +``` + +> [!NOTE] +> The `file` argument is optional and will empty by default, causing Castor to +> import and parse all the PHP files in the package. While handy, it's probably +> not what you want if your package contains PHP code that are not related to +> Castor. + +## Preventing remote imports + +In case you have trouble with the imported functions (or if you don't trust +them), you can prevent Castor from importing and running any of them. Add the +`--no-remote` option when calling any Castor tasks: + +```bash +$ castor --no-remote my-task +``` + +This will trigger a warning to remind you that the remote imports are disabled. +Also, any task or configuration using an imported function will trigger an error +with Castor complaining about undefined functions. + +If you want to disable remote imports every time, you can define the +`CASTOR_NO_REMOTE` environment variable to 1: + +```bash +$ export CASTOR_NO_REMOTE=1 +$ castor my-task # will not import any remote functions +``` + +## Update imported packages + +When you import a package in a given version, Castor will not update +automatically update the packages once a new version of your dependency is +available. + +To update your dependencies, you will either need to: +- change the required version yourself (thus every one using your Castor project +will profit of the update once they run your project); +- force the update on your side only by either using the `--update-remotes` +option or by removing the `.castor/vendor/` folder. + +```bash +$ castor --update-remotes +``` diff --git a/doc/going-further/index.md b/doc/going-further/index.md index 17db1faf..1e6f2b03 100644 --- a/doc/going-further/index.md +++ b/doc/going-further/index.md @@ -7,5 +7,5 @@ help you write your tasks. * [Interacting with Castor](interacting-with-castor/advanced-context.md): how to interact with Castor's output, context, logs, autocomplete, etc. * [Extending Castor](extending-castor/events.md): how to wire some logic -inside Castor, how to redistribute your project as your own phar or static -binary. +inside Castor, import remote tasks, redistribute your project as your own phar +or static binary, etc. diff --git a/examples/remote-import.php b/examples/remote-import.php new file mode 100644 index 00000000..3d30947e --- /dev/null +++ b/examples/remote-import.php @@ -0,0 +1,25 @@ + 'https://github.com/pyrech/castor-example-misc.git', + 'type' => 'git', + 'reference' => 'main', // commit id, branch or tag name +]); + +#[AsTask(description: 'Use functions imported from remote packages')] +function remote_tasks(): void +{ + \pyrech\helloExample(); // from composer://pyrech/castor-example + \pyrech\foobar(); // from package://pyrech/foobar +} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9c18cc20..3126631b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,6 +5,11 @@ parameters: count: 1 path: examples/args.php + - + message: "#^Function pyrech\\\\.* not found\\.$#" + count: 2 + path: examples/ + - message: "#^Default value of the parameter \\#1 \\$data \\(array\\{\\}\\) of method Castor\\\\Context\\:\\:__construct\\(\\) is incompatible with type array\\{name\\: string, production\\: bool, foo\\?\\: string\\}\\.$#" count: 1 diff --git a/phpstan.neon b/phpstan.neon index ad244234..a3e1c0a1 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -19,3 +19,10 @@ parameters: foo?: string, } ''' + ImportSource: ''' + array{ + url?: string, + type?: "git" | "svn", + reference?: string, + } + ''' diff --git a/src/Console/Application.php b/src/Console/Application.php index 0ab80f01..2637ca12 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -20,6 +20,7 @@ use Castor\Fingerprint\FingerprintHelper; use Castor\FunctionFinder; use Castor\GlobalHelper; +use Castor\Import\Importer; use Castor\PlatformHelper; use Castor\WaitForHelper; use Monolog\Logger; @@ -59,10 +60,11 @@ public function __construct( public readonly ExpressionLanguage $expressionLanguage, public readonly Logger $logger, public readonly Filesystem $fs, + public readonly WaitForHelper $waitForHelper, + public readonly FingerprintHelper $fingerprintHelper, + public readonly Importer $importer, public HttpClientInterface $httpClient, public CacheItemPoolInterface&CacheInterface $cache, - public WaitForHelper $waitForHelper, - public FingerprintHelper $fingerprintHelper, ) { $handler = ErrorHandler::register(); $handler->setDefaultLogger($logger, [ @@ -215,6 +217,24 @@ private function initializeApplication(InputInterface $input): TaskDescriptorCol )); } + $this->getDefinition()->addOption( + new InputOption( + 'no-remote', + null, + InputOption::VALUE_NONE, + 'Skip the import of all remote remote packages', + ) + ); + + $this->getDefinition()->addOption( + new InputOption( + 'update-remotes', + null, + InputOption::VALUE_NONE, + 'Force the update of remote packages', + ) + ); + return new TaskDescriptorCollection($tasks, $symfonyTasks); } diff --git a/src/Console/ApplicationFactory.php b/src/Console/ApplicationFactory.php index 93ce5456..1b64cac9 100644 --- a/src/Console/ApplicationFactory.php +++ b/src/Console/ApplicationFactory.php @@ -10,6 +10,10 @@ use Castor\ExpressionLanguage; use Castor\Fingerprint\FingerprintHelper; use Castor\FunctionFinder; +use Castor\Import\Importer; +use Castor\Import\Listener\RemoteImportListener; +use Castor\Import\Remote\Composer; +use Castor\Import\Remote\PackageImporter; use Castor\Listener\GenerateStubsListener; use Castor\Listener\UpdateCastorListener; use Castor\Monolog\Processor\ProcessProcessor; @@ -49,6 +53,9 @@ public static function create(): SymfonyApplication $cache = new FilesystemAdapter(directory: $cacheDir); $logger = new Logger('castor', [], [new ProcessProcessor()]); $fs = new Filesystem(); + $fingerprintHelper = new FingerprintHelper($cache); + $packageImporter = new PackageImporter($logger, new Composer($fs, $logger, $fingerprintHelper)); + $importer = new Importer($packageImporter, $logger); $eventDispatcher = new EventDispatcher(logger: $logger); $eventDispatcher->addSubscriber(new UpdateCastorListener( $cache, @@ -58,8 +65,9 @@ public static function create(): SymfonyApplication $eventDispatcher->addSubscriber(new GenerateStubsListener( new StubsGenerator($rootDir, $logger), )); + $eventDispatcher->addSubscriber(new RemoteImportListener($packageImporter)); - /** @var SymfonyApplication */ + /** @var Application */ // @phpstan-ignore-next-line $application = new $class( $rootDir, @@ -69,12 +77,16 @@ public static function create(): SymfonyApplication new ExpressionLanguage($contextRegistry), $logger, $fs, + new WaitForHelper($httpClient, $logger), + $fingerprintHelper, + $importer, $httpClient, $cache, - new WaitForHelper($httpClient, $logger), - new FingerprintHelper($cache), ); + // Avoid dependency cycle + $packageImporter->setApplication($application); + $application->setDispatcher($eventDispatcher); $application->add(new DebugCommand($rootDir, $cacheDir, $contextRegistry)); diff --git a/src/FunctionFinder.php b/src/FunctionFinder.php index 1f35b62b..8ec72b5c 100644 --- a/src/FunctionFinder.php +++ b/src/FunctionFinder.php @@ -247,16 +247,3 @@ private function resolveListeners(\ReflectionFunction $reflectionFunction): iter } } } - -// Don't leak internal variables -/** @internal */ -function castor_require(string $file): void -{ - if (!is_file($file)) { - throw new \RuntimeException(sprintf('Could not find file "%s".', $file)); - } - - FunctionFinder::$files[] = $file; - - require_once $file; -} diff --git a/src/Import/Exception/ComposerError.php b/src/Import/Exception/ComposerError.php new file mode 100644 index 00000000..2ebca25f --- /dev/null +++ b/src/Import/Exception/ComposerError.php @@ -0,0 +1,7 @@ +packageImporter->importPackage( + $scheme, + $package, + $file, + $version, + $vcs, + $source, + ); + + return; + } catch (ImportError $e) { + throw $this->createImportException($package, $e->getMessage(), $e); + } catch (RemoteNotAllowed $e) { + $this->logger->warning($this->getImportLocatedMessage($path, $e->getMessage(), 1)); + + return; + } + } elseif (null !== $file || null !== $version || null !== $vcs || null !== $source) { + throw $this->createImportException($path, 'The "file", "version", "vcs" and "source" arguments can only be used with a remote import.'); + } + + if (!file_exists($path)) { + throw $this->createImportException($path, sprintf('The file "%s" does not exist.', $path)); + } + + if (is_file($path)) { + castor_require($path); + } + + if (is_dir($path)) { + $files = Finder::create() + ->files() + ->name('*.php') + ->in($path) + ; + + foreach ($files as $file) { + castor_require($file->getPathname()); + } + } + } + + private function getImportLocatedMessage(string $path, string $reason, int $depth): string + { + /** @var array{file: string, line: int} $caller */ + $caller = debug_backtrace()[$depth + 1]; + + return sprintf( + 'Could not import "%s" in "%s" on line %d. Reason: %s', + $path, + $caller['file'], + $caller['line'], + $reason, + ); + } + + private function createImportException(string $path, string $message, ?\Throwable $e = null): \Throwable + { + $depth = 2; + + return fix_exception( + new \InvalidArgumentException($this->getImportLocatedMessage($path, $message, $depth), previous: $e), + $depth + ); + } +} diff --git a/src/Import/Listener/RemoteImportListener.php b/src/Import/Listener/RemoteImportListener.php new file mode 100644 index 00000000..95f2ffee --- /dev/null +++ b/src/Import/Listener/RemoteImportListener.php @@ -0,0 +1,28 @@ + 'afterInitialize', + ]; + } + + public function afterInitialize(AfterApplicationInitializationEvent $event): void + { + $this->packageImporter->fetchPackages($event->application->getInput()); + } +} diff --git a/src/Import/Remote/Composer.php b/src/Import/Remote/Composer.php new file mode 100644 index 00000000..8db27855 --- /dev/null +++ b/src/Import/Remote/Composer.php @@ -0,0 +1,139 @@ + 'This file is managed by Castor. Do not edit it manually.', + 'config' => [ + 'sort-packages' => true, + ], + 'replace' => [ + 'castor/castor' => Application::VERSION, + ], + ]; + + public function __construct( + private readonly Filesystem $filesystem, + private readonly LoggerInterface $logger, + private readonly FingerprintHelper $fingerprintHelper, + /** @var array */ + private array $configuration = self::DEFAULT_COMPOSER_CONFIGURATION, + ) { + } + + /** + * @return array + */ + public function getConfiguration(): array + { + return $this->configuration; + } + + /** + * @param array $configuration + */ + public function setConfiguration(array $configuration): void + { + $this->configuration = $configuration; + } + + public function update(bool $force = false, bool $displayProgress = true): void + { + $composer = (new ExecutableFinder())->find('composer'); + + if (!$composer) { + throw new ComposerError('The "composer" executable was not found. In order to use remote import, please make sure that Composer is installed and available in your PATH.'); + } + + $dir = PathHelper::getRoot() . self::VENDOR_DIR; + + $this->filesystem->mkdir($dir); + + file_put_contents($dir . '.gitignore', "*\n"); + + $this->writeJsonFile($dir . 'composer.json', $this->configuration); + + $ran = false; + $fingerprint = base64_encode(json_encode($this->configuration, \JSON_THROW_ON_ERROR)); + + if ($force || !$this->fingerprintHelper->verifyFingerprintFromHash($fingerprint)) { + $progressIndicator = null; + if ($displayProgress) { + $progressIndicator = new ProgressIndicator(GlobalHelper::getOutput(), null, 100, ['⠏', '⠛', '⠹', '⢸', '⣰', '⣤', '⣆', '⡇']); + $progressIndicator->start('Downloading remote packages'); + } + + $this->run(['update'], callback: function () use ($progressIndicator) { + if ($progressIndicator) { + $progressIndicator->advance(); + } + }); + + if ($progressIndicator) { + $progressIndicator->finish('Remote packages imported'); + } + $this->fingerprintHelper->postProcessFingerprintForHash($fingerprint); + + $ran = true; + } + + if (!$ran) { + $this->logger->debug('Packages were already required, no need to run Composer.'); + } + } + + public function remove(): void + { + $this->filesystem->remove(PathHelper::getRoot() . self::VENDOR_DIR); + } + + /** + * @param string[] $args + */ + private function run(array $args, callable $callback): void + { + $this->logger->debug('Running Composer command.', [ + 'args' => implode(' ', $args), + ]); + + $dir = PathHelper::getRoot() . self::VENDOR_DIR; + + $process = new Process(['composer', ...$args, '--working-dir', $dir]); + $process->setEnv([ + 'COMPOSER_VENDOR_DIR' => $dir, + ]); + $process->run($callback); + + if (!$process->isSuccessful()) { + throw new ComposerError('The Composer process failed: ' . $process->getErrorOutput()); + } + + $this->logger->debug('Composer command was successful.', [ + 'args' => implode(' ', $args), + 'output' => $process->getOutput(), + ]); + } + + /** + * @param array $json + */ + private function writeJsonFile(string $path, array $json): void + { + file_put_contents($path, json_encode($json, \JSON_PRETTY_PRINT | \JSON_THROW_ON_ERROR)); + } +} diff --git a/src/Import/Remote/PackageImporter.php b/src/Import/Remote/PackageImporter.php new file mode 100644 index 00000000..e35a4a47 --- /dev/null +++ b/src/Import/Remote/PackageImporter.php @@ -0,0 +1,188 @@ + */ + private array $imports = [], + ) { + } + + public function setApplication(Application $application): void + { + $this->application = $application; + } + + /** @phpstan-param ImportSource $source */ + public function importPackage(string $scheme, string $package, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void + { + if (!$this->allowsRemote()) { + throw new RemoteNotAllowed('Remote imports are disabled.'); + } + + if (isset($this->imports[$package]) && $this->imports[$package]->version !== $version) { + throw new ImportError(sprintf('The package "%s" is already required in version "%s", could not require it in version "%s"', $package, $this->imports[$package]->version, $version)); + } + + if (!preg_match('#^(?[^/]+)/(?[^/]+)$#', $package)) { + throw new InvalidImportFormat(sprintf('The import path must be formatted like this: "%s:///".', $scheme)); + } + + if ('composer' === $scheme) { + if (null !== $source) { + throw new InvalidImportFormat('The "source" argument is not supported for Composer/Packagist packages.'); + } + + $this->importPackageWithComposer($package, version: $version ?? '*', repositoryUrl: $vcs, file: $file); + + return; + } + + if ('package' === $scheme) { + if (null !== $version || null !== $vcs) { + throw new InvalidImportFormat('The "source" and "vcs" arguments are not supported for non-Composer packages.'); + } + if (null === $source) { + throw new InvalidImportFormat('The "source" argument is required for non-Composer packages.'); + } + + $this->importPackageWithComposer($package, version: 'v1', source: $source, file: $file); + + return; + } + + throw new InvalidImportFormat(sprintf('The import scheme "%s" is not supported.', $scheme)); + } + + public function fetchPackages(InputInterface $input): void + { + if (!$this->application) { + throw new \RuntimeException('The application must be set before calling fetchPackages()'); + } + + if (!$this->imports) { + $this->composer->remove(); + + return; + } + + // Need to look for the raw options as the input is not yet parsed + $forceUpdate = true !== $input->getParameterOption('--update-remotes', true); + $displayProgress = 'list' !== $input->getFirstArgument(); + + $autoloadPath = PathHelper::getRoot() . Composer::VENDOR_DIR . 'autoload.php'; + + if (!file_exists($autoloadPath)) { + $forceUpdate = true; + } + + $this->composer->update($forceUpdate, $displayProgress); + + require_once $autoloadPath; + + foreach ($this->imports as $package => $import) { + foreach ($import->getFiles() as $file) { + $this->application->importer->import(PathHelper::getRoot() . Composer::VENDOR_DIR . $package . '/' . ($file ?? '')); + } + } + } + + /** + * @param ?array{ + * url?: string, + * type?: "git" | "svn", + * reference?: string, + * } $source + */ + private function importPackageWithComposer(string $package, string $version, ?string $repositoryUrl = null, ?array $source = null, ?string $file = null): void + { + $this->logger->notice('Importing remote package with Composer.', [ + 'package' => $package, + 'version' => $version, + ]); + + $json = $this->composer->getConfiguration(); + + $json['require'][$package] = $version; + + if ($repositoryUrl) { + $json['repositories'][] = [ + 'type' => 'vcs', + 'url' => $repositoryUrl, + ]; + } + + if ($source) { + if (!isset($source['url'], $source['type'], $source['reference'])) { + throw new ImportError('The "source" argument must contain "url", "type" and "reference" keys.'); + } + + $json['repositories'][] = [ + 'type' => 'package', + 'package' => [ + 'name' => $package, + 'version' => $version, + 'source' => $source, + ], + ]; + } + + $this->composer->setConfiguration($json); + + $this->imports[$package] ??= new Import($version); + $this->imports[$package]->addFile($file); + } + + private function allowsRemote(): bool + { + if ($_SERVER['CASTOR_NO_REMOTE'] ?? false) { + return false; + } + + if (!$this->application) { + throw new \RuntimeException('The application must be set before calling allowsRemote()'); + } + + $input = $this->application->getInput(); + + // Need to look for the raw options as the input is not yet parsed + return true === $input->getParameterOption('--no-remote', true); + } +} + +class Import +{ + /** @var array */ + private array $files; + + public function __construct( + public readonly string $version, + ) { + } + + public function addFile(?string $file = null): void + { + $this->files[] = $file; + } + + /** @return array */ + public function getFiles(): array + { + return array_unique($this->files); + } +} diff --git a/src/functions-internal.php b/src/functions-internal.php new file mode 100644 index 00000000..f7aa6a81 --- /dev/null +++ b/src/functions-internal.php @@ -0,0 +1,38 @@ +getTrace()[$depth]; + foreach (['file', 'line'] as $key) { + if (!\array_key_exists($key, $lastFrame)) { + continue; + } + $r = new \ReflectionProperty(\Exception::class, $key); + $r->setValue($exception, $lastFrame[$key]); + } + + return $exception; +} diff --git a/src/functions.php b/src/functions.php index 4bbe4be9..71b94a1f 100644 --- a/src/functions.php +++ b/src/functions.php @@ -765,47 +765,16 @@ function http_client(): HttpClientInterface return GlobalHelper::getHttpClient(); } -function import(string $path): void -{ - if (!file_exists($path)) { - throw fix_exception(new \InvalidArgumentException(sprintf('The file "%s" does not exist.', $path))); - } - - if (is_file($path)) { - castor_require($path); - } - - if (is_dir($path)) { - $files = Finder::create() - ->files() - ->name('*.php') - ->in($path) - ; - - foreach ($files as $file) { - castor_require($file->getPathname()); - } - } -} - /** - * Remove the last frame (the call to run() to display a nice message to the end user. - * - * @internal + * @param ?array{ + * url?: string, + * type?: "git" | "svn", + * reference?: string, + * } $source */ -function fix_exception(\Exception $exception): \Exception +function import(string $path, ?string $file = null, ?string $version = null, ?string $vcs = null, ?array $source = null): void { - $lastFrame = $exception->getTrace()[0]; - foreach (['file', 'line'] as $key) { - if (!\array_key_exists($key, $lastFrame)) { - continue; - } - $r = new \ReflectionProperty(\Exception::class, $key); - $r->setAccessible(true); - $r->setValue($exception, $lastFrame[$key]); - } - - return $exception; + GlobalHelper::getApplication()->importer->import($path, $file, $version, $vcs, $source); } /** diff --git a/tests/Examples/Generated/AutocompleteInvalidTest.php b/tests/Examples/Generated/AutocompleteInvalidTest.php index 03b5c992..2531c1e2 100644 --- a/tests/Examples/Generated/AutocompleteInvalidTest.php +++ b/tests/Examples/Generated/AutocompleteInvalidTest.php @@ -9,7 +9,7 @@ class AutocompleteInvalidTest extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/autocomplete-invalid'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/autocomplete-invalid', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Generated/ContextGeneratorArg2Test.php b/tests/Examples/Generated/ContextGeneratorArg2Test.php index 995c4c27..bbf112be 100644 --- a/tests/Examples/Generated/ContextGeneratorArg2Test.php +++ b/tests/Examples/Generated/ContextGeneratorArg2Test.php @@ -9,7 +9,7 @@ class ContextGeneratorArg2Test extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-arg-2'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-arg-2', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Generated/ContextGeneratorArgTest.php b/tests/Examples/Generated/ContextGeneratorArgTest.php index b9051317..965066a2 100644 --- a/tests/Examples/Generated/ContextGeneratorArgTest.php +++ b/tests/Examples/Generated/ContextGeneratorArgTest.php @@ -9,7 +9,7 @@ class ContextGeneratorArgTest extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-arg'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-arg', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Generated/ContextGeneratorNotCallableTest.php b/tests/Examples/Generated/ContextGeneratorNotCallableTest.php index c6ce84ed..22fc735c 100644 --- a/tests/Examples/Generated/ContextGeneratorNotCallableTest.php +++ b/tests/Examples/Generated/ContextGeneratorNotCallableTest.php @@ -9,7 +9,7 @@ class ContextGeneratorNotCallableTest extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-not-callable'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/context-generator-not-callable', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Generated/FilesystemFindTest.php.output.txt b/tests/Examples/Generated/FilesystemFindTest.php.output.txt index d20e33cf..b76f9390 100644 --- a/tests/Examples/Generated/FilesystemFindTest.php.output.txt +++ b/tests/Examples/Generated/FilesystemFindTest.php.output.txt @@ -1 +1 @@ -Number of PHP files: 29 +Number of PHP files: 30 diff --git a/tests/Examples/Generated/ImportComposerSourceTest.php b/tests/Examples/Generated/ImportComposerSourceTest.php new file mode 100644 index 00000000..86cc9e6d --- /dev/null +++ b/tests/Examples/Generated/ImportComposerSourceTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-composer-source', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportComposerSourceTest.php.err.txt b/tests/Examples/Generated/ImportComposerSourceTest.php.err.txt new file mode 100644 index 00000000..2e7635e4 --- /dev/null +++ b/tests/Examples/Generated/ImportComposerSourceTest.php.err.txt @@ -0,0 +1,10 @@ +In castor.php line 5: + + Could not import "foo/bar" in ".../tests/Examples/fixtures/broken/import-composer-source/castor.php" on line 5. Reason: The "source" argument is not supported for Composer/Packagist packages. + + +In PackageImporter.php line 48: + + The "source" argument is not supported for Composer/Packagist packages. + + diff --git a/tests/Examples/Generated/ImportComposerSourceTest.php.output.txt b/tests/Examples/Generated/ImportComposerSourceTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ImportFileNotExistTest.php b/tests/Examples/Generated/ImportFileNotExistTest.php new file mode 100644 index 00000000..20252b38 --- /dev/null +++ b/tests/Examples/Generated/ImportFileNotExistTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-file-not-exist', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportFileNotExistTest.php.err.txt b/tests/Examples/Generated/ImportFileNotExistTest.php.err.txt new file mode 100644 index 00000000..8f470b48 --- /dev/null +++ b/tests/Examples/Generated/ImportFileNotExistTest.php.err.txt @@ -0,0 +1,5 @@ +In castor.php line 5: + + Could not import "path/to/not/existing/castor.php" in ".../tests/Examples/fixtures/broken/import-file-not-exist/castor.php" on line 5. Reason: The file "path/to/not/existing/castor.php" does not exist. + + diff --git a/tests/Examples/Generated/ImportFileNotExistTest.php.output.txt b/tests/Examples/Generated/ImportFileNotExistTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ImportInvalidFormatTest.php b/tests/Examples/Generated/ImportInvalidFormatTest.php new file mode 100644 index 00000000..f5365499 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidFormatTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-invalid-format', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportInvalidFormatTest.php.err.txt b/tests/Examples/Generated/ImportInvalidFormatTest.php.err.txt new file mode 100644 index 00000000..3f7d0e85 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidFormatTest.php.err.txt @@ -0,0 +1,10 @@ +In castor.php line 5: + + Could not import "invalid-package-name" in ".../tests/Examples/fixtures/broken/import-invalid-format/castor.php" on line 5. Reason: The import path must be formatted like this: "composer:///". + + +In PackageImporter.php line 43: + + The import path must be formatted like this: "composer:///". + + diff --git a/tests/Examples/Generated/ImportInvalidFormatTest.php.output.txt b/tests/Examples/Generated/ImportInvalidFormatTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ImportInvalidImportTest.php b/tests/Examples/Generated/ImportInvalidImportTest.php new file mode 100644 index 00000000..ac35bb25 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidImportTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-invalid-import', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportInvalidImportTest.php.err.txt b/tests/Examples/Generated/ImportInvalidImportTest.php.err.txt new file mode 100644 index 00000000..dd9b2321 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidImportTest.php.err.txt @@ -0,0 +1,5 @@ +In castor.php line 5: + + Could not import "path-to-castor.php" in ".../tests/Examples/fixtures/broken/import-invalid-import/castor.php" on line 5. Reason: The "file", "version", "vcs" and "source" arguments can only be used with a remote import. + + diff --git a/tests/Examples/Generated/ImportInvalidImportTest.php.output.txt b/tests/Examples/Generated/ImportInvalidImportTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ImportInvalidPackageTest.php b/tests/Examples/Generated/ImportInvalidPackageTest.php new file mode 100644 index 00000000..157427a0 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidPackageTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-invalid-package', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportInvalidPackageTest.php.err.txt b/tests/Examples/Generated/ImportInvalidPackageTest.php.err.txt new file mode 100644 index 00000000..d1804ab9 --- /dev/null +++ b/tests/Examples/Generated/ImportInvalidPackageTest.php.err.txt @@ -0,0 +1,10 @@ +In castor.php line 5: + + Could not import "foo/bar" in ".../tests/Examples/fixtures/broken/import-invalid-package/castor.php" on line 5. Reason: The "source" argument must contain "url", "type" and "reference" keys. + + +In PackageImporter.php line 132: + + The "source" argument must contain "url", "type" and "reference" keys. + + diff --git a/tests/Examples/Generated/ImportInvalidPackageTest.php.output.txt b/tests/Examples/Generated/ImportInvalidPackageTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php b/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php new file mode 100644 index 00000000..44f27478 --- /dev/null +++ b/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php @@ -0,0 +1,22 @@ +runTask([], '{{ base }}/tests/Examples/fixtures/broken/import-same-package-different-version', needRemote: true); + + $this->assertSame(1, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); + if (file_exists(__FILE__ . '.err.txt')) { + $this->assertStringEqualsFile(__FILE__ . '.err.txt', $process->getErrorOutput()); + } else { + $this->assertSame('', $process->getErrorOutput()); + } + } +} diff --git a/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php.err.txt b/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php.err.txt new file mode 100644 index 00000000..a8c796c5 --- /dev/null +++ b/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php.err.txt @@ -0,0 +1,10 @@ +In castor.php line 6: + + Could not import "pyrech/castor-example" in ".../tests/Examples/fixtures/broken/import-same-package-different-version/castor.php" on line 6. Reason: The package "pyrech/castor-example" is already required in version "^1.0", could not require it in version "^2.0" + + +In PackageImporter.php line 39: + + The package "pyrech/castor-example" is already required in version "^1.0", could not require it in version "^2.0" + + diff --git a/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php.output.txt b/tests/Examples/Generated/ImportSamePackageDifferentVersionTest.php.output.txt new file mode 100644 index 00000000..e69de29b diff --git a/tests/Examples/Generated/ListTest.php.output.txt b/tests/Examples/Generated/ListTest.php.output.txt index f8936c12..fca33b24 100644 --- a/tests/Examples/Generated/ListTest.php.output.txt +++ b/tests/Examples/Generated/ListTest.php.output.txt @@ -52,6 +52,7 @@ output:output Plays with Sym parallel:exception Sleep and throw an exception parallel:sleep Sleeps for 5, 7, and 10 seconds in parallel quiet:quiet Executes something but does not output anything +remote-import:remote-tasks Use functions imported from remote packages run:ls Run a sub-process and display information about it run:test-file Run a sub-process and return its exit code, with get_exit_code() function run:variables Run a sub-process with environment variables and display information about it diff --git a/tests/Examples/Generated/NoDefaultContextTest.php b/tests/Examples/Generated/NoDefaultContextTest.php index fe7286d0..83bd4059 100644 --- a/tests/Examples/Generated/NoDefaultContextTest.php +++ b/tests/Examples/Generated/NoDefaultContextTest.php @@ -9,7 +9,7 @@ class NoDefaultContextTest extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/no-default-context'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/no-default-context', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Generated/TwoDefaultContextTest.php b/tests/Examples/Generated/TwoDefaultContextTest.php index 77667b72..8a0128e7 100644 --- a/tests/Examples/Generated/TwoDefaultContextTest.php +++ b/tests/Examples/Generated/TwoDefaultContextTest.php @@ -9,7 +9,7 @@ class TwoDefaultContextTest extends TaskTestCase // no task public function test(): void { - $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/two-default-context'); + $process = $this->runTask([], '{{ base }}/tests/Examples/fixtures/broken/two-default-context', needRemote: true); $this->assertSame(1, $process->getExitCode()); $this->assertStringEqualsFile(__FILE__ . '.output.txt', $process->getOutput()); diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php new file mode 100644 index 00000000..696cd54b --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php @@ -0,0 +1,30 @@ +remove(__DIR__ . '/../../../.castor/vendor'); + + // No vendor => should download + $process = $this->runTask(['remote-import:remote-tasks'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_update.txt', $process->getOutput()); + + // Vendor downloaded => should not download + $process = $this->runTask(['remote-import:remote-tasks'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_no_update.txt', $process->getOutput()); + + // Force remotes update => should update + $process = $this->runTask(['remote-import:remote-tasks', '--update-remotes'], needRemote: true); + $this->assertSame(0, $process->getExitCode()); + $this->assertStringEqualsFile(__FILE__ . '.output_update.txt', $process->getOutput()); + } +} diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt new file mode 100644 index 00000000..4223ba3b --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_no_update.txt @@ -0,0 +1,6 @@ +Hello from example! +=================== + +This is foobar! +=============== + diff --git a/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt new file mode 100644 index 00000000..3441d8c2 --- /dev/null +++ b/tests/Examples/Remote/RemoteImportRemoteTasksTest.php.output_update.txt @@ -0,0 +1,10 @@ + Downloading remote packages + Remote packages imported + + +Hello from example! +=================== + +This is foobar! +=============== + diff --git a/tests/Examples/fixtures/broken/import-composer-source/castor.php b/tests/Examples/fixtures/broken/import-composer-source/castor.php new file mode 100644 index 00000000..700119fa --- /dev/null +++ b/tests/Examples/fixtures/broken/import-composer-source/castor.php @@ -0,0 +1,5 @@ + 'git']); diff --git a/tests/Examples/fixtures/broken/import-file-not-exist/castor.php b/tests/Examples/fixtures/broken/import-file-not-exist/castor.php new file mode 100644 index 00000000..d4ba0fde --- /dev/null +++ b/tests/Examples/fixtures/broken/import-file-not-exist/castor.php @@ -0,0 +1,5 @@ + 'git']); diff --git a/tests/Examples/fixtures/broken/import-same-package-different-version/castor.php b/tests/Examples/fixtures/broken/import-same-package-different-version/castor.php new file mode 100644 index 00000000..62464849 --- /dev/null +++ b/tests/Examples/fixtures/broken/import-same-package-different-version/castor.php @@ -0,0 +1,6 @@ +getTestResultObject()?->getCodeCoverage(); @@ -24,14 +24,16 @@ public function runTask(array $args, ?string $cwd = null): Process 'ENDPOINT' => $_SERVER['ENDPOINT'], ]; + if (!$needRemote) { + $extraEnv['CASTOR_NO_REMOTE'] = 1; + } + if ($coverage) { $castorBin = __DIR__ . '/bin/castor'; $testName = debug_backtrace()[1]['class'] . '::' . debug_backtrace()[1]['function']; $outputFilename = stream_get_meta_data(tmpfile())['uri']; - $extraEnv = [ - 'CC_OUTPUT_FILENAME' => $outputFilename, - 'CC_TEST_NAME' => $testName, - ]; + $extraEnv['CC_OUTPUT_FILENAME'] = $outputFilename; + $extraEnv['CC_TEST_NAME'] = $testName; } $process = new Process(