diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..06e5f5b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +/tests export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/.travis.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..abb9c75 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/vendor +/composer.lock +/phpunit.xml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..25a1563 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: php + +php: + - '7.1' + - '7.2' + - nightly + +env: + - COMPOSER_COMMAND="composer install" + - COMPOSER_COMMAND="composer update --prefer-lowest" + +install: ${COMPOSER_COMMAND} + +script: ./vendor/bin/phpunit --colors diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 0000000..9d47be0 --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,7 @@ +Changelog +######### + +1.0.0 +***** + +Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..707d80e --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2018 Pavel Batečko (ShiraNai7) + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..d37c8bb --- /dev/null +++ b/README.rst @@ -0,0 +1,404 @@ +Composer package scripts +######################## + +Composer plugin that provides a way for packages to expose custom scripts +to the root project. These scripts work similarly to the root-only +scripts_ option. + +.. image:: https://travis-ci.org/kuria/composer-pkg-scripts.svg?branch=master + :target: https://travis-ci.org/kuria/composer-pkg-scripts + +.. contents:: + :depth: 2 + + +Requirements +************ + +- PHP 7.1+ +- Composer 1.6+ + + +Terminology +*********** + +root package + the main package (project) + +root script + a script defined in the root package's scripts_ option + +package script + a script defined in a package's ``extra.package-scripts`` option + + +Installation +************ + +Specify ``kuria/composer-pkg-scripts`` as a dependency in your ``composer.json``. + +This can be done either in the root package or in one of the required packages +(perhaps a `metapackage? `_). +That depends entirely on your use case. + +.. code:: javascript + + { + // ... + "require": { + "kuria/composer-pkg-scripts": "^1.0" + } + } + + +Defining package scripts +************************ + +Package scripts can be defined in the *composer.json*'s extra_ option. + +The syntax is identical to the root-only scripts_ option. +See `Composer docs - defining scripts `_. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "hello-world": "echo Hello world!", + "php-version": "php -v" + } + } + } + +The final script names are automatically prefixed by the package name. + +The example above will define the following scripts: + +- ``acme:example:hello-world`` +- ``acme:example:php-version`` + +To define shorter aliases, see `Specifying aliases and help`_. + +.. NOTE:: + + Package scripts will **not** override root scripts_ with the same name. + +.. NOTE:: + + Package scripts defined in the root package will not be loaded. + Use scripts_ instead. + + +Referencing other scripts +========================= + +In addition to the root scripts_, package scripts may reference other package +scripts defined in the same file. + +See `Composer docs - referencing scripts `_. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "all": ["@first", "@second", "@third"], + "first": "echo first", + "second": "echo second", + "third": "echo third" + } + } + } + +Package scripts of other packages may be referenced using their full name +or alias (if it exists). Using the full name should be preferred. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "another-foo": "@acme:another:foo" + } + } + } + + +Specifying aliases and help +=========================== + +Package script aliases and help can be defined in the *composer.json*'s extra_ +option. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "hello-world": "echo Hello world!", + "php-version": "php -v" + }, + "package-scripts-meta": { + "hello-world": {"aliases": "hello", "help": "An example command"}, + "php-version": {"aliases": ["phpv", "pv"], "help": "Show PHP version"} + } + } + } + +Unlike script names, aliases are not automatically prefixed by the package name. + +The example above will define the following scripts: + +- ``acme:example:hello-world`` +- ``acme:example:php-version`` +- ``hello`` +- ``phpv`` +- ``pv`` + +.. NOTE:: + + Package script aliases will **not** override root scripts_ or other aliases + with the same name. + + +Specifying aliases in the root package +-------------------------------------- + +If a package doesn't provide suitable aliases, the root package may define them +in its scripts_ option. + +.. code:: javascript + + { + "name": "acme/project", + // ... + "scripts": { + "acme-hello": "@acme:example:hello-world" + } + } + + +Using variables +=============== + +Unlike root scripts_, package scripts may use variable placeholders. + +The syntax of the placeholder is: + +:: + + {$variable-name} + +- variable name can consist of any characters other than "}" +- nonexistent variables resolve to an empty string +- the final value is escaped by ``escapeshellarg()`` +- array variables will be imploded and separated by spaces, with each + value escaped by ``escapeshellarg()`` + + +Composer configuration +---------------------- + +All Composer configuration directives are available through variables. + +See `Composer docs - config `_. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "list-vendors": "ls {$vendor-dir}" + } + } + } + + +Package variables +----------------- + +Packages may define their own variables in the *composer.json*'s extra_ option. + + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "hello": "echo {$name}" + }, + "package-scripts-vars": { + "name": "Bob" + } + } + } + +These defaults may then be overriden in the root package, if needed: + +.. code:: javascript + + { + "name": "acme/project", + // ... + "extra": { + "package-scripts-vars": { + "acme/example": { + "name": "John" + } + } + } + } + + +Referencing other variables +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Package variables may reference `composer configuration directives `_ +or other package variables belonging to the same package. + +.. code:: javascript + + { + "name": "acme/example", + // ... + "extra": { + "package-scripts": { + "hello": "echo Hello {$names}", + "show-paths": "echo {$paths}" + }, + "package-scripts-vars": { + "names": ["Bob", "{$other-names}"], + "other-names": ["John", "Nick"], + "paths": ["{$vendor-dir}", "{$bin-dir}"] + } + } + } + +.. code:: bash + + composer acme:example:hello + +:: + + > echo Hello "Bob" "John" "Nick" + Hello Bob John Nick + + +.. code:: bash + + composer acme:example:show-paths + +:: + + > echo "/project/vendor" "/project/vendor/bin" + /project/vendor /project/vendor/bin + + +.. NOTE:: + + Array variables must be referenced directly, e.g. ``"{$array-var}"``, + not embedded in the middle of a string. + + Nested array variable references are flattened into a simple list, as seen + in the examples above. + + +Running package scripts +*********************** + +Package scripts can be invoked the same way root scripts_ can: + +1. ``composer run-script acme:example:hello-world`` +2. ``composer acme:example:hello-world`` + +See `Composer docs - running scripts manually `_. + + +Using package scripts in events +******************************* + +Package scripts may be used in event scripts (provided the plugin is loaded +at that point). + +.. code:: javascript + + { + "name": "acme/project", + // ... + "scripts": { + "post-install-cmd": "@acme:example:hello-world" + } + } + + +Listing package scripts +*********************** + +This plugin provides a command called ``package-scripts:list``, which lists both +active and inactive package scripts and aliases. + +.. code:: bash + + composer package-scripts:list + +:: + + Available package scripts: + acme:example:hello-world (hello) An example command + acme:example:php-version (phpv, pv) Show PHP version + +Enabling verbose mode will show additonal information: + +.. code:: bash + + composer package-scripts:list -v + +:: + + Available package scripts: + acme:example:hello-world Run the "hello-world" script from acme/example + - package: acme/example + - definition: "echo Hello world!" + - aliases: + acme:example:php-version Run the "php-version" script from acme/example + - package: acme/example + - definition: "php -v" + - aliases: + +You may use the ``psl`` alias instead of the full command name. + + +Debugging package scripts and variables +*************************************** + +This plugin provides a command called ``package-scripts:dump``, which dumps +compiled scripts (including root scripts) or package script variables. + +.. code:: bash + + composer package-scripts:dump + +Specifying the ``--vars`` flag will dump compiled package script variables +instead: + +.. code:: bash + + composer package-scripts:dump --vars + +You may use the ``psd`` alias instead of the full command name. + + +.. _scripts: https://getcomposer.org/doc/04-schema.md#scripts +.. _extra: https://getcomposer.org/doc/04-schema.md#extra diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..4ec561e --- /dev/null +++ b/composer.json @@ -0,0 +1,28 @@ +{ + "name": "kuria/composer-pkg-scripts", + "type": "composer-plugin", + "description": "Composer plugin that provides a way for packages to expose scripts to the root project", + "keywords": ["composer", "package", "scripts", "script"], + "license": "MIT", + "require": { + "php": ">=7.1", + "composer-plugin-api": "^1.0" + }, + "require-dev": { + "composer/composer": "^1.6", + "phpunit/phpunit": "^7.0" + }, + "autoload": { + "psr-4": { + "Kuria\\ComposerPkgScripts\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "Kuria\\ComposerPkgScripts\\": "tests" + } + }, + "extra": { + "class": "Kuria\\ComposerPkgScripts\\Plugin" + } +} \ No newline at end of file diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..656af88 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,13 @@ + + + + + ./tests + + + + + ./src + + + diff --git a/src/Capability/ScriptCommandProvider.php b/src/Capability/ScriptCommandProvider.php new file mode 100644 index 0000000..274e3aa --- /dev/null +++ b/src/Capability/ScriptCommandProvider.php @@ -0,0 +1,46 @@ +scriptManager = $args['plugin']->getScriptManager(); + } + + function getCommands() + { + $commands = [ + new ListPackageScriptsCommand($this->scriptManager), + new DumpPackageScriptsCommand($this->scriptManager), + ]; + + foreach ($this->scriptManager->getRegisteredScripts() as $name => $script) { + $scriptCommand = new ScriptAliasCommand($name, $script->help); + $scriptCommand->setHelp(sprintf( + <<<'HELP' +The %s command runs the "%s" script defined by %s. + +HELP + , + $name, + $script->shortName, + $script->package + )); + + $commands[] = $scriptCommand; + } + + return $commands; + } +} diff --git a/src/Command/DumpPackageScriptsCommand.php b/src/Command/DumpPackageScriptsCommand.php new file mode 100644 index 0000000..1a26153 --- /dev/null +++ b/src/Command/DumpPackageScriptsCommand.php @@ -0,0 +1,68 @@ +scriptManager = $scriptManager; + } + + protected function configure() + { + $this->setName('package-scripts:dump'); + $this->setAliases(['psd']); + $this->addOption('vars', null, InputOption::VALUE_NONE, 'Dump script variables'); + $this->setDescription('Dump compiled scripts (including root package scripts)'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + if ($input->getOption('vars')) { + $value = $this->scriptManager->getPackageVariables(); + } else { + $value = $this->scriptManager->getCompiledScripts(); + } + + $this->dump($value, $output); + } + + private function dump($value, OutputInterface $output, int $level = 0): void + { + if (is_array($value)) { + if (empty($value)) { + $output->write('[]'); + } + + if ($level > 0) { + $output->write("\n"); + } + + foreach ($value as $k => $v) { + if ($level > 0) { + $output->write(str_repeat(' ', $level)); + } + + $output->write(sprintf('%s: ', $k)); + + $this->dump($v, $output, $level + 1); + } + + return; + } + + $output->writeln(sprintf('%s', $value)); + } +} diff --git a/src/Command/ListPackageScriptsCommand.php b/src/Command/ListPackageScriptsCommand.php new file mode 100644 index 0000000..9f2ca14 --- /dev/null +++ b/src/Command/ListPackageScriptsCommand.php @@ -0,0 +1,149 @@ +scriptManager = $scriptManager; + } + + protected function configure() + { + $this->setName('package-scripts:list'); + $this->setAliases(['psl']); + $this->setDescription('List available package scripts'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + // list available package scripts + $output->writeln('Available package scripts:'); + $this->getScriptList($output)->render(); + + // list inactive scripts, if any + $inactiveScripts = $this->listInactiveScripts(); + + if ($inactiveScripts->valid()) { + $output->writeln(''); + $output->writeln('Inactive package scripts:'); + $this->getInactiveScriptList($output, $inactiveScripts)->render(); + $output->writeln(''); + $output->writeln('Package script or alias is inactive if it conflicts with another package script, alias or a root script.'); + } + } + + private function getScriptList(OutputInterface $output): Table + { + $list = new Table($output); + $list->setStyle('compact'); + + $registeredScripts = $this->scriptManager->getRegisteredScripts(); + + // determine unique scripts (exclude aliases) + $uniqueScripts = []; + + foreach ($registeredScripts as $script) { + $uniqueScripts[$script->name] = $script; + } + + ksort($uniqueScripts, SORT_NATURAL); + + foreach ($uniqueScripts as $script) { + $activeAliases = $this->getActiveAliases($script, $registeredScripts); + + $aliasList = !empty($activeAliases) && !$output->isVerbose() + ? sprintf(' (%s)', implode(', ', $activeAliases)) + : ''; + + $list->addRow([sprintf(' %s%s', $script->name, $aliasList), $script->help]); + + if ($output->isVerbose()) { + $extraInfo = implode("\n", [ + ' - package: ' . $script->package, + ' - definition: ' . json_encode($script->definition, JSON_UNESCAPED_SLASHES), + ' - aliases: ' . implode(', ', $activeAliases), + ]); + + $list->addRow([new TableCell(sprintf('%s', $extraInfo), ['colspan' => 3])]); + } + } + + return $list; + } + + private function getInactiveScriptList(OutputInterface $output, \Traversable $inactiveScripts): Table + { + $list = new Table($output); + $list->setStyle('compact'); + + foreach ($inactiveScripts as $inactiveScript) { + if ($inactiveScript['type'] === 'script') { + // script + $list->addRow([ + $inactiveScript['script']->package, + sprintf(' script "%s"', $inactiveScript['script']->name), + ]); + } else { + // alias + $list->addRow([ + $inactiveScript['script']->package, + sprintf(' alias "%s" of "%s"', $inactiveScript['alias'], $inactiveScript['script']->name), + ]); + } + } + + return $list; + } + + private function listInactiveScripts(): \Iterator + { + $registeredScripts = $this->scriptManager->getRegisteredScripts(); + + foreach ($this->scriptManager->getLoadedScripts() as $script) { + if (!isset($registeredScripts[$script->name]) || $registeredScripts[$script->name] !== $script) { + yield [ + 'type' => 'script', + 'script' => $script, + ]; + } + + foreach ($script->aliases as $alias) { + if (!isset($registeredScripts[$alias]) || $registeredScripts[$alias] !== $script) { + yield [ + 'type' => 'alias', + 'alias' => $alias, + 'script' => $script, + ]; + } + } + } + } + + private function getActiveAliases(Script $script, array $registeredScripts): array + { + $activeAliases = []; + + foreach ($script->aliases as $alias) { + if (isset($registeredScripts[$alias]) && $registeredScripts[$alias] === $script) { + $activeAliases[] = $alias; + } + } + + return $activeAliases; + } +} diff --git a/src/Exception/InvalidScriptVariableException.php b/src/Exception/InvalidScriptVariableException.php new file mode 100644 index 0000000..0bbd848 --- /dev/null +++ b/src/Exception/InvalidScriptVariableException.php @@ -0,0 +1,7 @@ +composer = $composer; + $this->scriptManager = new ScriptManager($composer); + } + + function getCapabilities() + { + return [ + CommandProvider::class => ScriptCommandProvider::class, + ]; + } + + static function getSubscribedEvents() + { + return [ + PluginEvents::INIT => 'registerScripts', + ScriptEvents::POST_INSTALL_CMD => 'registerScripts', + ScriptEvents::POST_UPDATE_CMD => 'registerScripts', + ]; + } + + function registerScripts(): void + { + $this->scriptManager->registerScripts(); + } + + function getScriptManager(): ScriptManager + { + return $this->scriptManager; + } +} diff --git a/src/Script/Script.php b/src/Script/Script.php new file mode 100644 index 0000000..fec84c2 --- /dev/null +++ b/src/Script/Script.php @@ -0,0 +1,35 @@ +package = $package; + $this->shortName = $shortName; + $this->name = $name; + $this->aliases = $aliases; + $this->definition = $definition; + $this->help = $help; + } +} diff --git a/src/Script/ScriptCompiler.php b/src/Script/ScriptCompiler.php new file mode 100644 index 0000000..e5fd3dd --- /dev/null +++ b/src/Script/ScriptCompiler.php @@ -0,0 +1,159 @@ +definition as $listener) { + $listeners[] = $this->compileScriptListener($script, (string) $listener); + } + + return $listeners; + } + + function getGlobalVariables(): array + { + return $this->globalVariables; + } + + function setGlobalVariables(array $globalVariables): void + { + $this->globalVariables = $globalVariables; + } + + function getPackageVariables(): array + { + return $this->packageVariables; + } + + function setPackageVariables(array $packageVariables): void + { + $this->packageVariables = $packageVariables; + $this->compiledPackageVariables = null; + } + + public function setShellArgEscaper(callable $shellArgEscaper): void + { + $this->shellArgEscaper = $shellArgEscaper; + } + + private function compileScriptListener(Script $script, string $listener): string + { + return preg_replace_callback( + self::VARIABLE_PLACEHOLDER_REGEXP, + function (array $match) use ($script) { + $value = $this->compilePackageVariable($script->package, $match[1]) + ?? $this->globalVariables[$match[1]] + ?? ''; + + return is_array($value) + ? implode(' ', array_map($this->shellArgEscaper, $value)) + : ($this->shellArgEscaper)($value); + }, + $listener + ); + } + + private function compilePackageVariable(string $package, string $variable, array $visited = []) + { + if (isset($this->compiledPackageVariables[$package][$variable])) { + // already compiled + return $this->compiledPackageVariables[$package][$variable]; + } + + if (!isset($this->packageVariables[$package][$variable])) { + // unknown variable + return null; + } + + if (isset($visited[$variable])) { + // circular reference + throw new ScriptCompilerException(sprintf( + 'Circular reference to package script variable [%s] detected at [%s][%s]', + $variable, + $package, + implode('][', array_keys($visited)) + )); + } + + // compile + $visited[$variable] = true; + + try { + return $this->compiledPackageVariables[$package][$variable] = $this->resolvePackageVariableValue( + $package, + $this->packageVariables[$package][$variable], + $visited + ); + } catch (InvalidScriptVariableException $e) { + throw new ScriptCompilerException( + sprintf('Failed to compile package script variable [%s][%s] - %s', $package, $variable, $e->getMessage()), + 0, + $e + ); + } + } + + private function resolvePackageVariableValue(string $package, $value, array $visited) + { + // handle array + if (is_array($value)) { + $resolvedValue = []; + + foreach ($value as $item) { + $resolvedItemValue = (array) $this->resolvePackageVariableValue($package, $item, $visited); + + if ($resolvedItemValue) { + array_push($resolvedValue, ...$resolvedItemValue); + } + } + + return $resolvedValue; + } + + $value = (string) $value; + + // resolve direct variable reference + if (preg_match(self::DIRECT_VARIABLE_REFERENCE_REGEXP, $value, $match)) { + return $this->compilePackageVariable($package, $match[1], $visited) + ?? $this->globalVariables[$match[1]] + ?? ''; + } + + // resolve variable placeholders + return preg_replace_callback( + self::VARIABLE_PLACEHOLDER_REGEXP, + function (array $match) use ($package, $visited) { + $value = $this->compilePackageVariable($package, $match[1], $visited) + ?? $this->globalVariables[$match[1]] + ?? ''; + + if (is_array($value)) { + throw new InvalidScriptVariableException(sprintf('Cannot embed array variable [%s]', $match[1])); + } + + return (string) $value; + }, + $value + ); + } +} diff --git a/src/Script/ScriptLoader.php b/src/Script/ScriptLoader.php new file mode 100644 index 0000000..ac77587 --- /dev/null +++ b/src/Script/ScriptLoader.php @@ -0,0 +1,107 @@ +getExtra(); + + if (empty($extra[static::EXTRA_SCRIPTS_KEY])) { + continue; + } + + $packageName = $package->getName(); + + $scriptNames = $this->resolveScriptNames($packageName, $extra[static::EXTRA_SCRIPTS_KEY]); + + foreach ($extra[static::EXTRA_SCRIPTS_KEY] as $shortName => $definition) { + $name = $scriptNames[$shortName]; + + // determine aliases + $aliases = (array) ($extra[static::EXTRA_SCRIPTS_META_KEY][$shortName]['aliases'] ?? []); + + // determine help + $help = $extra[static::EXTRA_SCRIPTS_META_KEY][$shortName]['help'] + ?? sprintf('Run the "%s" script from %s', $shortName, $packageName); + + // resolve definition + if (is_array($definition)) { + $definition = array_map( + function ($listener) use ($scriptNames) { return $this->resolveScriptListener($scriptNames, (string) $listener); }, + $definition + ); + } else { + $definition = $this->resolveScriptListener($scriptNames, (string) $definition); + } + + // add script + $scripts[$name] = new Script($packageName, $shortName, $name, $aliases, $definition, $help); + } + } + + return $scripts; + } + + /** + * @param PackageInterface[] $packages + */ + function loadScriptVariables(PackageInterface $rootPackage, array $packages): array + { + $variables = []; + + // load variables from packages + foreach ($packages as $package) { + $variables[$package->getName()] = $package->getExtra()[static::EXTRA_SCRIPTS_VARIABLES_KEYS] ?? []; + } + + // load variables from root package + foreach ($rootPackage->getExtra()[static::EXTRA_SCRIPTS_VARIABLES_KEYS] ?? [] as $packageName => $rootVariables) { + if (isset($variables[$packageName])) { + $variables[$packageName] = $rootVariables + $variables[$packageName]; + } + } + + return $variables; + } + + private function resolveScriptNames(string $packageName, array $packageScripts): array + { + $names = []; + + $prefix = strtr($packageName, '/', ':') . ':'; + + foreach ($packageScripts as $shortName => $_) { + $names[$shortName] = $prefix . $shortName; + } + + return $names; + } + + private function resolveScriptListener(array $scriptNames, string $listener): string + { + if ( + $listener !== '' + && $listener[0] === '@' + && isset($scriptNames[$referencedScriptName = substr($listener, 1)]) + ) { + $listener = '@' . $scriptNames[$referencedScriptName]; + } + + return $listener; + } +} diff --git a/src/Script/ScriptManager.php b/src/Script/ScriptManager.php new file mode 100644 index 0000000..60f0835 --- /dev/null +++ b/src/Script/ScriptManager.php @@ -0,0 +1,105 @@ +composer = $composer; + $this->loader = $loader ?? new ScriptLoader(); + $this->compiler = $compiler ?? new ScriptCompiler(); + $this->compiler->setGlobalVariables($composer->getConfig()->all()['config']); + } + + function registerScripts(): void + { + $rootPackage = $this->composer->getPackage(); + + if (!$rootPackage instanceof CompletePackage) { + return; + } + + $rootScripts = $rootPackage->getScripts(); + $packages = $this->composer->getRepositoryManager()->getLocalRepository()->getPackages(); + + // clear previously registered scripts + foreach ($this->registeredScripts as $scriptName => $_) { + unset($rootScripts[$scriptName]); + } + + // reset state + $this->registeredScripts = []; + $this->loadedScripts = []; + + // setup compiler + $this->compiler->setPackageVariables($this->loader->loadScriptVariables($rootPackage, $packages)); + + // add package scripts + $this->loadedScripts = $this->loader->loadScripts($packages); + + foreach ($this->loadedScripts as $script) { + $listeners = $this->compiler->compile($script); + + if (!isset($rootScripts[$script->name])) { + $rootScripts[$script->name] = $listeners; + $this->registeredScripts[$script->name] = $script; + } + + foreach ($script->aliases as $alias) { + if (!isset($rootScripts[$alias])) { + $rootScripts[$alias] = ['@' . $script->name]; + $this->registeredScripts[$alias] = $script; + } + } + } + + $rootPackage->setScripts($rootScripts); + } + + /** + * @return Script[] + */ + function getRegisteredScripts(): array + { + return $this->registeredScripts; + } + + /** + * @return Script[] + */ + function getLoadedScripts(): array + { + return $this->loadedScripts; + } + + /** + * Get currently compiled scripts, including root scripts + */ + function getCompiledScripts(): array + { + return $this->composer->getPackage()->getScripts(); + } + + /** + * Get package variables used during last script compilation + */ + function getPackageVariables(): array + { + return $this->compiler->getPackageVariables(); + } +} diff --git a/tests/Capability/ScriptCommandProviderTest.php b/tests/Capability/ScriptCommandProviderTest.php new file mode 100644 index 0000000..dcb0d02 --- /dev/null +++ b/tests/Capability/ScriptCommandProviderTest.php @@ -0,0 +1,55 @@ +createConfiguredMock(ScriptManager::class, [ + 'getRegisteredScripts' => [ + 'acme:example:foo' => $fooScript = new Script('acme/example', 'foo', 'acme:example:foo', ['foo'], 'acme foo', 'foo help'), + 'foo' => $fooScript, + 'acme:example:baz' => new Script('acme/example', 'baz', 'acme:example:baz', [], 'acme baz', 'baz help'), + ], + ]); + + $pluginMock = $this->createConfiguredMock(Plugin::class, [ + 'getScriptManager' => $scriptManagerMock, + ]); + + $commandProvider = new ScriptCommandProvider(['plugin' => $pluginMock]); + + /** @var Command[] $commands */ + $commands = $commandProvider->getCommands(); + + $this->assertCount(5, $commands); + $this->assertInstanceOf(ListPackageScriptsCommand::class, $commands[0]); + $this->assertInstanceOf(DumpPackageScriptsCommand::class, $commands[1]); + $this->assertInstanceOf(ScriptAliasCommand::class, $commands[2]); + $this->assertInstanceOf(ScriptAliasCommand::class, $commands[3]); + $this->assertInstanceOf(ScriptAliasCommand::class, $commands[4]); + + $this->assertSame('acme:example:foo', $commands[2]->getName()); + $this->assertSame('foo help', $commands[2]->getDescription()); + $this->assertContains('"foo"', $commands[2]->getHelp()); + + $this->assertSame('foo', $commands[3]->getName()); + $this->assertSame('foo help', $commands[3]->getDescription()); + $this->assertContains('"foo"', $commands[3]->getHelp()); + + $this->assertSame('acme:example:baz', $commands[4]->getName()); + $this->assertSame('baz help', $commands[4]->getDescription()); + $this->assertContains('"baz"', $commands[4]->getHelp()); + } +} diff --git a/tests/Script/ScriptCompilerTest.php b/tests/Script/ScriptCompilerTest.php new file mode 100644 index 0000000..e8e485a --- /dev/null +++ b/tests/Script/ScriptCompilerTest.php @@ -0,0 +1,223 @@ +compiler = new ScriptCompiler(); + + // output of escapeshellarg() is platform-dependant + $this->compiler->setShellArgEscaper(function ($arg) { + return '"' . addcslashes($arg, '"') . '"'; + }); + } + + function testShouldConfigure() + { + $globalVariables = ['foo' => 'bar']; + $packageVariables = ['acme/example' => ['baz' => 'qux']]; + + $this->compiler->setGlobalVariables($globalVariables); + $this->compiler->setPackageVariables($packageVariables); + + $this->assertSame($globalVariables, $this->compiler->getGlobalVariables()); + $this->assertSame($packageVariables, $this->compiler->getPackageVariables()); + } + + /** + * @dataProvider provideScripts + */ + function testShouldCompile( + Script $script, + array $globalVariables, + array $packageVariables, + array $expectedResult + ) { + $this->compiler->setGlobalVariables($globalVariables); + $this->compiler->setPackageVariables([$script->package => $packageVariables]); + + $this->assertSame($expectedResult, $this->compiler->compile($script)); + } + + function provideScripts(): array + { + return [ + // script, globalVariables, packageVariables, expectedResult + 'Empty string' => [ + $this->createScript(''), + [], + [], + [''], + ], + 'Simple string' => [ + $this->createScript('foo'), + [], + [], + ['foo'], + ], + 'String with nonexistent var' => [ + $this->createScript('echo {$nonexistent}'), + [], + [], + ['echo ""'], + ], + 'String with global var' => [ + $this->createScript('echo {$var}'), + ['var' => 'value'], + [], + ['echo "value"'], + ], + 'String with package var' => [ + $this->createScript('echo {$var}'), + ['var' => 'global value'], + ['var' => 'package value'], + ['echo "package value"'], + ], + 'String with multiple vars' => [ + $this->createScript('echo {$global-var} {$package-var}'), + ['global-var' => 'global value'], + ['package-var' => 'package value'], + ['echo "global value" "package value"'], + ], + 'String with array var' => [ + $this->createScript('echo {$array-var}'), + ['array-var' => ['foo', 'bar', 'baz']], + [], + ['echo "foo" "bar" "baz"'], + ], + 'String with complex package var' => [ + $this->createScript('echo {$var}'), + ['global-var' => '/global'], + ['var' => '{$global-var}/var/{$other-var}', 'other-var' => 'other'], + ['echo "/global/var/other"'], + ], + 'String with reused package var' => [ + $this->createScript('echo {$var}'), + [], + ['var' => '{$other-var} {$other-var}', 'other-var' => 'other'], + ['echo "other other"'], + ], + 'String with direct package array variable reference' => [ + $this->createScript('echo {$var}'), + [], + ['var' => '{$other-var}', 'other-var' => ['foo', 'bar']], + ['echo "foo" "bar"'], + ], + 'String with direct global array variable reference' => [ + $this->createScript('echo {$var}'), + ['global-var' => ['foo', 'bar']], + ['var' => '{$global-var}'], + ['echo "foo" "bar"'], + ], + 'String with nested array references' => [ + $this->createScript('echo {$var}'), + [], + [ + 'var' => ['foo', '{$bar}', 'baz'], + 'bar' => ['bar 1', 'bar 2', '{$more-bars}', '{$no-bars}'], + 'more-bars' => ['bar 3', 'bar 4'], + 'no-bars' => [], + ], + ['echo "foo" "bar 1" "bar 2" "bar 3" "bar 4" "baz"'], + ], + 'String with unsupported placeholders in global var' => [ + $this->createScript('echo {$global-var} {$var}'), + ['global-var' => '{$placeholder}'], + ['var' => 'global={$global-var}'], + ['echo "{$placeholder}" "global={$placeholder}"'], + ], + 'String with complex array package var' => [ + $this->createScript('echo {$var}'), + ['global-var' => '/global'], + ['var' => ['{$foo}', '{$bar}', 'baz'], 'foo' => '/foo', 'bar' => '{$global-var}/bar'], + ['echo "/foo" "/global/bar" "baz"'], + ], + 'Empty array' => [ + $this->createScript([]), + [], + [], + [], + ], + 'Array with vars' => [ + $this->createScript([ + 'echo hello', + 'echo {$global-var}', + 'echo {$package-var}', + 'echo {$array-var}', + 'echo {$complex-var}', + ]), + ['global-var' => 'global value', 'array-var' => ['foo', 'bar', 'baz']], + ['package-var' => 'package value', 'complex-var' => 'global={$global-var}'], + [ + 'echo hello', + 'echo "global value"', + 'echo "package value"', + 'echo "foo" "bar" "baz"', + 'echo "global=global value"', + ], + ], + ]; + } + + /** + * @dataProvider provideScriptsWithInvalidVariables + */ + function testShouldThrowExceptionWhenVariableCannotBeCompiled( + Script $script, + array $globalVariables, + array $packageVariables, + string $expectedMessage + ) { + $this->compiler->setGlobalVariables($globalVariables); + $this->compiler->setPackageVariables([$script->package => $packageVariables]); + + $this->expectException(ScriptCompilerException::class); + $this->expectExceptionMessage($expectedMessage); + + $this->compiler->compile($script); + } + + function provideScriptsWithInvalidVariables(): array + { + return [ + // script, globalVariables, packageVariables, expectedMessage + 'Embedded array package var' => [ + $this->createScript('echo {$var}'), + [], + ['var' => 'foo {$pkg-array-var} bar', 'pkg-array-var' => ['foo', 'bar']], + 'Failed to compile package script variable [acme/example][var] - Cannot embed array variable [pkg-array-var]', + ], + 'Embedded global array var' => [ + $this->createScript('echo {$var}'), + ['global-array-var' => ['foo', 'bar']], + ['var' => 'foo {$global-array-var} bar'], + 'Failed to compile package script variable [acme/example][var] - Cannot embed array variable [global-array-var]', + ], + 'Direct circular reference' => [ + $this->createScript('echo {$var}'), + [], + ['var' => '{$var}'], + 'Circular reference to package script variable [var] detected at [acme/example][var]', + ], + 'Deep circular reference' => [ + $this->createScript('echo {$var}'), + [], + ['var' => '{$foo}', 'foo' => '{$bar}', 'bar' => '{$baz}', 'baz' => '{$foo}'], + 'Circular reference to package script variable [foo] detected at [acme/example][var][foo][bar][baz]', + ], + ]; + } + + private function createScript($definition): Script + { + return new Script('acme/example', 'short-name', 'name', [], $definition, 'help'); + } +} diff --git a/tests/Script/ScriptLoaderTest.php b/tests/Script/ScriptLoaderTest.php new file mode 100644 index 0000000..8925de1 --- /dev/null +++ b/tests/Script/ScriptLoaderTest.php @@ -0,0 +1,119 @@ +createConfiguredMock(PackageInterface::class, [ + 'getExtra' => [ + ScriptLoader::EXTRA_SCRIPTS_VARIABLES_KEYS => [ + 'acme/complex' => [ + 'foo' => 'overridden', + ], + ], + ], + ]); + + /** @var PackageInterface[] $packages */ + $packages = [ + $this->createConfiguredMock(PackageInterface::class, [ + 'getName' => 'acme/empty', + 'getExtra' => [], + ]), + $basicPackage = $this->createConfiguredMock(PackageInterface::class, [ + 'getName' => 'acme/basic', + 'getExtra' => [ + ScriptLoader::EXTRA_SCRIPTS_KEY => [ + 'foo' => 'echo foo', + 'bar' => ['echo bar', 'echo baz'], + ], + ], + ]), + $complexPackage = $this->createConfiguredMock(PackageInterface::class, [ + 'getName' => 'acme/complex', + 'getExtra' => [ + ScriptLoader::EXTRA_SCRIPTS_KEY => [ + 'lorem' => ['@ipsum', '@dolor'], + 'ipsum' => '@some-script', + 'dolor' => '@acme:basic:foo', + ], + ScriptLoader::EXTRA_SCRIPTS_META_KEY => [ + 'lorem' => ['aliases' => 'lorem'], + 'ipsum' => ['aliases' => ['ipsum', 'ips'], 'help' => 'ipsum help'], + ], + ScriptLoader::EXTRA_SCRIPTS_VARIABLES_KEYS => [ + 'foo' => 'foo', + 'bar' => 'bar', + ], + ], + ]), + ]; + + $expectedScripts = [ + 'acme:basic:foo' => [ + 'package' => 'acme/basic', + 'shortName' => 'foo', + 'name' => 'acme:basic:foo', + 'aliases' => [], + 'definition' => 'echo foo', + 'help' => 'Run the "foo" script from acme/basic', + ], + 'acme:basic:bar' => [ + 'package' => 'acme/basic', + 'shortName' => 'bar', + 'name' => 'acme:basic:bar', + 'aliases' => [], + 'definition' => ['echo bar', 'echo baz'], + 'help' => 'Run the "bar" script from acme/basic', + ], + 'acme:complex:lorem' => [ + 'package' => 'acme/complex', + 'shortName' => 'lorem', + 'name' => 'acme:complex:lorem', + 'aliases' => ['lorem'], + 'definition' => ['@acme:complex:ipsum', '@acme:complex:dolor'], + 'help' => 'Run the "lorem" script from acme/complex', + ], + 'acme:complex:ipsum' => [ + 'package' => 'acme/complex', + 'shortName' => 'ipsum', + 'name' => 'acme:complex:ipsum', + 'aliases' => ['ipsum', 'ips'], + 'definition' => '@some-script', + 'help' => 'ipsum help', + ], + 'acme:complex:dolor' => [ + 'package' => 'acme/complex', + 'shortName' => 'dolor', + 'name' => 'acme:complex:dolor', + 'aliases' => [], + 'definition' => '@acme:basic:foo', + 'help' => 'Run the "dolor" script from acme/complex', + ], + ]; + + $expectedVariables = [ + 'acme/empty' => [], + 'acme/basic' => [], + 'acme/complex' => [ + 'foo' => 'overridden', + 'bar' => 'bar', + ], + ]; + + $loader = new ScriptLoader(); + + $scripts = $loader->loadScripts($packages); + $variables = $loader->loadScriptVariables($rootPackage, $packages); + + $this->assertSame($expectedScripts, array_map(function ($script) { return (array) $script; }, $scripts)); + $this->assertSame($expectedVariables, $variables); + } +} diff --git a/tests/Script/ScriptManagerTest.php b/tests/Script/ScriptManagerTest.php new file mode 100644 index 0000000..414e959 --- /dev/null +++ b/tests/Script/ScriptManagerTest.php @@ -0,0 +1,196 @@ +createMock(CompletePackage::class); + $loaderMock = $this->createMock(ScriptLoader::class); + $compilerMock = $this->createTestProxy(ScriptCompiler::class); + + $scripts = [ + 'acme:example:foo' => new Script('acme/example', 'foo', 'acme:example:foo', ['foo', 'acme-foo'], 'acme foo', 'help'), + 'acme:example:bar' => new Script('acme/example', 'bar', 'acme:example:bar', [], ['acme bar', 'acme qux'], 'help'), + 'acme:example:baz' => new Script('acme/example', 'baz', 'acme:example:baz', [], 'acme baz', 'help'), + ]; + + $rootPackageScripts = [ + 'foo' => ['root foo'], + 'bar' => ['root bar'], + 'acme:example:baz' => ['overridden'], + ]; + + $compiledScripts = $rootPackageScripts + [ + 'acme:example:foo' => ['acme foo'], + 'acme-foo' => ['@acme:example:foo'], + 'acme:example:bar' => ['acme bar', 'acme qux'], + ]; + + $packageVariables = [ + 'acme/example' => ['var' => 'value'], + 'acme/other' => [], + ]; + + $rootPackageMock + ->method('getScripts') + ->willReturnReference($rootPackageScripts); + + $compilerMock->expects($this->once()) + ->method('setGlobalVariables') + ->with(['global-config' => 'value']); + + $compilerMock->expects($this->once()) + ->method('setPackageVariables') + ->with($this->identicalTo($packageVariables)); + + $loaderMock->expects($this->once()) + ->method('loadScripts') + ->willReturn($scripts); + + $loaderMock->expects($this->once()) + ->method('loadScriptVariables') + ->with($this->identicalTo($rootPackageMock), $this->isType('array')) + ->willReturn($packageVariables); + + $rootPackageMock->expects($this->once()) + ->method('setScripts') + ->with($this->identicalTo($compiledScripts)) + ->willReturnCallback(function (array $scripts) use (&$rootPackageScripts) { + $rootPackageScripts = $scripts; + }); + + $manager = $this->createScriptManager($rootPackageMock, $loaderMock, $compilerMock); + + $manager->registerScripts(); + + $this->assertSame( + [ + 'acme:example:foo' => $scripts['acme:example:foo'], + 'acme-foo' => $scripts['acme:example:foo'], + 'acme:example:bar' => $scripts['acme:example:bar'], + ], + $manager->getRegisteredScripts() + ); + $this->assertSame($scripts, $manager->getLoadedScripts()); + $this->assertSame($compiledScripts, $manager->getCompiledScripts()); + $this->assertSame($packageVariables, $manager->getPackageVariables()); + } + + function testShouldClearPreviouslyRegisteredScripts() + { + $rootPackageMock = $this->createMock(CompletePackage::class); + $loaderMock = $this->createMock(ScriptLoader::class); + + $firstScriptSet = [ + 'acme:example:foo' => new Script('acme/example', 'foo', 'acme:example:foo', ['foo', 'acme-foo'], 'acme foo', 'help'), + 'acme:example:bar' => new Script('acme/example', 'bar', 'acme:example:bar', [], 'acme bar', 'help'), + ]; + + $secondScriptSet = [ + 'acme:example:baz' => new Script('acme/example', 'baz', 'acme:example:baz', [], 'acme baz', 'help'), + ]; + + $rootScripts = [ + 'foo' => ['root foo'], + 'bar' => ['root bar'], + ]; + + $scriptsAfterFirstCall = $rootScripts + [ + 'acme:example:foo' => ['acme foo'], + 'acme-foo' => ['@acme:example:foo'], + 'acme:example:bar' => ['acme bar'], + ]; + + $scriptsAfterSecondCall = $rootScripts + [ + 'acme:example:baz' => ['acme baz'], + ]; + + $rootPackageMock->expects($this->exactly(2)) + ->method('getScripts') + ->willReturnOnConsecutiveCalls( + $rootScripts, + $scriptsAfterFirstCall + ); + + $loaderMock->expects($this->exactly(2)) + ->method('loadScripts') + ->willReturnOnConsecutiveCalls( + $firstScriptSet, + $secondScriptSet + ); + + $rootPackageMock->expects($this->exactly(2)) + ->method('setScripts') + ->withConsecutive( + [$this->identicalTo($scriptsAfterFirstCall)], + [$this->identicalTo($scriptsAfterSecondCall)] + ); + + $manager = $this->createScriptManager($rootPackageMock, $loaderMock); + + // first call + $manager->registerScripts(); + + $this->assertSame($firstScriptSet, $manager->getLoadedScripts()); + $this->assertSame( + [ + 'acme:example:foo' => $firstScriptSet['acme:example:foo'], + 'acme-foo' => $firstScriptSet['acme:example:foo'], + 'acme:example:bar' => $firstScriptSet['acme:example:bar'], + ], + $manager->getRegisteredScripts() + ); + + // second call - should clear scripts from first call + $manager->registerScripts(); + + $this->assertSame($secondScriptSet, $manager->getLoadedScripts()); + $this->assertSame( + [ + 'acme:example:baz' => $secondScriptSet['acme:example:baz'], + ], + $manager->getRegisteredScripts() + ); + } + + function testShouldDoNothingIfRootPackageIsIncompatibleInstance() + { + $rootPackage = $this->createMock(RootPackageInterface::class); + + $rootPackage->expects($this->never()) + ->method('getScripts'); + + $manager = $this->createScriptManager($rootPackage, new ScriptLoader()); + + $manager->registerScripts(); + } + + private function createScriptManager($rootPackage, $scriptLoader = null, $scriptCompiler = null): ScriptManager + { + /** @var Composer $composerMock */ + $composerMock = $this->createConfiguredMock(Composer::class, [ + 'getPackage' => $rootPackage, + 'getConfig' => $this->createConfiguredMock(Config::class, [ + 'all' => ['config' => ['global-config' => 'value']], + ]), + 'getRepositoryManager' => $this->createConfiguredMock(RepositoryManager::class, [ + 'getLocalRepository' => $this->createConfiguredMock(WritableRepositoryInterface::class, [ + 'getPackages' => [], + ]) + ]) + ]); + + return new ScriptManager($composerMock, $scriptLoader, $scriptCompiler); + } +} diff --git a/tests/Script/ScriptTest.php b/tests/Script/ScriptTest.php new file mode 100644 index 0000000..aa29cfd --- /dev/null +++ b/tests/Script/ScriptTest.php @@ -0,0 +1,20 @@ +assertSame('acme/example', $script->package); + $this->assertSame('short-name', $script->shortName); + $this->assertSame('name', $script->name); + $this->assertSame(['alias'], $script->aliases); + $this->assertSame('echo foo', $script->definition); + $this->assertSame('help', $script->help); + } +}