close(); } $this->consoleInput = $input ?: new ArgvInput; $this->consoleOutput = $output ?: new ConsoleOutput; $this->terminal = new Terminal; // Call the constructor as late as possible (it runs `initialise`). parent::__construct($config); } /** * Adds a command object. * * If a command with the same name already exists, it will be overridden. If the command is not enabled it will not be added. * * @param AbstractCommand $command The command to add to the application. * * @return AbstractCommand * * @since 2.0.0 * @throws LogicException */ public function addCommand(AbstractCommand $command): AbstractCommand { $this->initCommands(); if (!$command->isEnabled()) { return $command; } $command->setApplication($this); try { $command->getDefinition(); } catch (\TypeError $exception) { throw new LogicException(sprintf('Command class "%s" is not correctly initialised.', \get_class($command)), 0, $exception); } if (!$command->getName()) { throw new LogicException(sprintf('The command class "%s" does not have a name.', \get_class($command))); } $this->commands[$command->getName()] = $command; foreach ($command->getAliases() as $alias) { $this->commands[$alias] = $command; } return $command; } /** * Configures the console input and output instances for the process. * * @return void * * @since 2.0.0 */ protected function configureIO(): void { if ($this->consoleInput->hasParameterOption(['--ansi'], true)) { $this->consoleOutput->setDecorated(true); } elseif ($this->consoleInput->hasParameterOption(['--no-ansi'], true)) { $this->consoleOutput->setDecorated(false); } if ($this->consoleInput->hasParameterOption(['--no-interaction', '-n'], true)) { $this->consoleInput->setInteractive(false); } if ($this->consoleInput->hasParameterOption(['--quiet', '-q'], true)) { $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_QUIET); $this->consoleInput->setInteractive(false); } else { if ($this->consoleInput->hasParameterOption('-vvv', true) || $this->consoleInput->hasParameterOption('--verbose=3', true) || $this->consoleInput->getParameterOption('--verbose', false, true) === 3 ) { $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_DEBUG); } elseif ($this->consoleInput->hasParameterOption('-vv', true) || $this->consoleInput->hasParameterOption('--verbose=2', true) || $this->consoleInput->getParameterOption('--verbose', false, true) === 2 ) { $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); } elseif ($this->consoleInput->hasParameterOption('-v', true) || $this->consoleInput->hasParameterOption('--verbose=1', true) || $this->consoleInput->hasParameterOption('--verbose', true) || $this->consoleInput->getParameterOption('--verbose', false, true) ) { $this->consoleOutput->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); } } } /** * Method to run the application routines. * * @return integer The exit code for the application * * @since 2.0.0 * @throws \Throwable */ protected function doExecute(): int { $input = $this->consoleInput; $output = $this->consoleOutput; // If requesting the version, short circuit the application and send the version data if ($input->hasParameterOption(['--version', '-V'], true)) { $output->writeln($this->getLongVersion()); return 0; } try { // Makes ArgvInput::getFirstArgument() able to distinguish an option from an argument. $input->bind($this->getDefinition()); } catch (ExceptionInterface $e) { // Errors must be ignored, full binding/validation happens later when the command is known. } $name = $this->getCommandName($input); // Redirect to the help command if requested if ($input->hasParameterOption(['--help', '-h'], true)) { // If no command name was given, use the help command with a minimal input; otherwise flag the request for processing later if (!$name) { $name = 'help'; $input = new ArrayInput(['command_name' => $this->defaultCommand]); } else { $this->wantsHelp = true; } } // If we still do not have a command name, then the user has requested the application's default command if (!$name) { $name = $this->defaultCommand; $definition = $this->getDefinition(); // Overwrite the default value of the command argument with the default command name $definition->setArguments( array_merge( $definition->getArguments(), [ 'command' => new InputArgument( 'command', InputArgument::OPTIONAL, $definition->getArgument('command')->getDescription(), $name ), ] ) ); } try { $this->runningCommand = null; $command = $this->getCommand($name); } catch (\Throwable $e) { if ($e instanceof CommandNotFoundException && !($e instanceof NamespaceNotFoundException)) { (new SymfonyStyle($input, $output))->block(sprintf("\nCommand \"%s\" is not defined.\n", $name), null, 'error'); } $event = new CommandErrorEvent($e, $this); $this->dispatchEvent(ConsoleEvents::COMMAND_ERROR, $event); if ($event->getExitCode() === 0) { return 0; } $e = $event->getError(); throw $e; } $this->runningCommand = $command; $exitCode = $this->runCommand($command, $input, $output); $this->runningCommand = null; return $exitCode; } /** * Execute the application. * * @return void * * @since 2.0.0 * @throws \Throwable */ public function execute() { putenv('LINES=' . $this->terminal->getHeight()); putenv('COLUMNS=' . $this->terminal->getWidth()); $this->configureIO(); $renderThrowable = function (\Throwable $e) { $this->renderThrowable($e); }; if ($phpHandler = set_exception_handler($renderThrowable)) { restore_exception_handler(); if (!\is_array($phpHandler) || !$phpHandler[0] instanceof ErrorHandler) { $errorHandler = true; } elseif ($errorHandler = $phpHandler[0]->setExceptionHandler($renderThrowable)) { $phpHandler[0]->setExceptionHandler($errorHandler); } } try { $this->dispatchEvent(ApplicationEvents::BEFORE_EXECUTE); // Perform application routines. $exitCode = $this->doExecute(); $this->dispatchEvent(ApplicationEvents::AFTER_EXECUTE); } catch (\Throwable $throwable) { if (!$this->shouldCatchThrowables()) { throw $throwable; } $renderThrowable($throwable); $event = new ApplicationErrorEvent($throwable, $this, $this->runningCommand); $this->dispatchEvent(ConsoleEvents::APPLICATION_ERROR, $event); $exitCode = $event->getExitCode(); if (is_numeric($exitCode)) { $exitCode = (int) $exitCode; if ($exitCode === 0) { $exitCode = 1; } } else { $exitCode = 1; } } finally { // If the exception handler changed, keep it; otherwise, unregister $renderThrowable if (!$phpHandler) { if (set_exception_handler($renderThrowable) === $renderThrowable) { restore_exception_handler(); } restore_exception_handler(); } elseif (!$errorHandler) { $finalHandler = $phpHandler[0]->setExceptionHandler(null); if ($finalHandler !== $renderThrowable) { $phpHandler[0]->setExceptionHandler($finalHandler); } } if ($this->shouldAutoExit() && isset($exitCode)) { $exitCode = $exitCode > 255 ? 255 : $exitCode; $this->close($exitCode); } } } /** * Finds a registered namespace by a name. * * @param string $namespace A namespace to search for * * @return string * * @since 2.0.0 * @throws NamespaceNotFoundException When namespace is incorrect or ambiguous */ public function findNamespace(string $namespace): string { $allNamespaces = $this->getNamespaces(); $expr = preg_replace_callback( '{([^:]+|)}', function ($matches) { return preg_quote($matches[1]) . '[^:]*'; }, $namespace ); $namespaces = preg_grep('{^' . $expr . '}', $allNamespaces); if (empty($namespaces)) { throw new NamespaceNotFoundException(sprintf('There are no commands defined in the "%s" namespace.', $namespace)); } $exact = \in_array($namespace, $namespaces, true); if (\count($namespaces) > 1 && !$exact) { throw new NamespaceNotFoundException(sprintf('The namespace "%s" is ambiguous.', $namespace)); } return $exact ? $namespace : reset($namespaces); } /** * Gets all commands, including those available through a command loader, optionally filtered on a command namespace. * * @param string $namespace An optional command namespace to filter by. * * @return AbstractCommand[] * * @since 2.0.0 */ public function getAllCommands(string $namespace = ''): array { $this->initCommands(); if ($namespace === '') { $commands = $this->commands; if (!$this->commandLoader) { return $commands; } foreach ($this->commandLoader->getNames() as $name) { if (!isset($commands[$name])) { $commands[$name] = $this->getCommand($name); } } return $commands; } $commands = []; foreach ($this->commands as $name => $command) { if ($namespace === $this->extractNamespace($name, substr_count($namespace, ':') + 1)) { $commands[$name] = $command; } } if ($this->commandLoader) { foreach ($this->commandLoader->getNames() as $name) { if (!isset($commands[$name]) && $namespace === $this->extractNamespace($name, substr_count($namespace, ':') + 1)) { $commands[$name] = $this->getCommand($name); } } } return $commands; } /** * Returns a registered command by name or alias. * * @param string $name The command name or alias * * @return AbstractCommand * * @since 2.0.0 * @throws CommandNotFoundException */ public function getCommand(string $name): AbstractCommand { $this->initCommands(); if (!$this->hasCommand($name)) { throw new CommandNotFoundException(sprintf('The command "%s" does not exist.', $name)); } // If the command isn't registered, pull it from the loader if registered if (!isset($this->commands[$name]) && $this->commandLoader) { $this->addCommand($this->commandLoader->get($name)); } $command = $this->commands[$name]; // If the user requested help, we'll fetch the help command now and inject the user's command into it if ($this->wantsHelp) { $this->wantsHelp = false; /** @var HelpCommand $helpCommand */ $helpCommand = $this->getCommand('help'); $helpCommand->setCommand($command); return $helpCommand; } return $command; } /** * Get the name of the command to run. * * @param InputInterface $input The input to read the argument from * * @return string|null * * @since 2.0.0 */ protected function getCommandName(InputInterface $input): ?string { return $input->getFirstArgument(); } /** * Get the registered commands. * * This method only retrieves commands which have been explicitly registered. To get all commands including those from a * command loader, use the `getAllCommands()` method. * * @return AbstractCommand[] * * @since 2.0.0 */ public function getCommands(): array { return $this->commands; } /** * Get the console input handler. * * @return InputInterface * * @since 2.0.0 */ public function getConsoleInput(): InputInterface { return $this->consoleInput; } /** * Get the console output handler. * * @return OutputInterface * * @since 2.0.0 */ public function getConsoleOutput(): OutputInterface { return $this->consoleOutput; } /** * Get the commands which should be registered by default to the application. * * @return AbstractCommand[] * * @since 2.0.0 */ protected function getDefaultCommands(): array { return [ new Command\ListCommand, new Command\HelpCommand, ]; } /** * Builds the default input definition. * * @return InputDefinition * * @since 2.0.0 */ protected function getDefaultInputDefinition(): InputDefinition { return new InputDefinition( [ new InputArgument('command', InputArgument::REQUIRED, 'The command to execute'), new InputOption('--help', '-h', InputOption::VALUE_NONE, 'Display the help information'), new InputOption('--quiet', '-q', InputOption::VALUE_NONE, 'Flag indicating that all output should be silenced'), new InputOption( '--verbose', '-v|vv|vvv', InputOption::VALUE_NONE, 'Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug' ), new InputOption('--version', '-V', InputOption::VALUE_NONE, 'Displays the application version'), new InputOption('--ansi', '', InputOption::VALUE_NONE, 'Force ANSI output'), new InputOption('--no-ansi', '', InputOption::VALUE_NONE, 'Disable ANSI output'), new InputOption('--no-interaction', '-n', InputOption::VALUE_NONE, 'Flag to disable interacting with the user'), ] ); } /** * Builds the default helper set. * * @return HelperSet * * @since 2.0.0 */ protected function getDefaultHelperSet(): HelperSet { return new HelperSet( [ new FormatterHelper, new DebugFormatterHelper, new ProcessHelper, new QuestionHelper, ] ); } /** * Gets the InputDefinition related to this Application. * * @return InputDefinition * * @since 2.0.0 */ public function getDefinition(): InputDefinition { if (!$this->definition) { $this->definition = $this->getDefaultInputDefinition(); } return $this->definition; } /** * Get the helper set associated with the application. * * @return HelperSet */ public function getHelperSet(): HelperSet { if (!$this->helperSet) { $this->helperSet = $this->getDefaultHelperSet(); } return $this->helperSet; } /** * Get the long version string for the application. * * Typically, this is the application name and version and is used in the application help output. * * @return string * * @since 2.0.0 */ public function getLongVersion(): string { $name = $this->getName(); if ($name === '') { $name = 'Joomla Console Application'; } if ($this->getVersion() !== '') { return sprintf('%s %s', $name, $this->getVersion()); } return $name; } /** * Get the name of the application. * * @return string * * @since 2.0.0 */ public function getName(): string { return $this->name; } /** * Returns an array of all unique namespaces used by currently registered commands. * * Note that this does not include the global namespace which always exists. * * @return string[] * * @since 2.0.0 */ public function getNamespaces(): array { $namespaces = []; foreach ($this->getAllCommands() as $command) { $namespaces = array_merge($namespaces, $this->extractAllNamespaces($command->getName())); foreach ($command->getAliases() as $alias) { $namespaces = array_merge($namespaces, $this->extractAllNamespaces($alias)); } } return array_values(array_unique(array_filter($namespaces))); } /** * Get the version of the application. * * @return string * * @since 2.0.0 */ public function getVersion(): string { return $this->version; } /** * Check if the application has a command with the given name. * * @param string $name The name of the command to check for existence. * * @return boolean * * @since 2.0.0 */ public function hasCommand(string $name): bool { $this->initCommands(); // If command is already registered, we're good if (isset($this->commands[$name])) { return true; } // If there is no loader, we can't look for a command there if (!$this->commandLoader) { return false; } return $this->commandLoader->has($name); } /** * Custom initialisation method. * * @return void * * @since 2.0.0 */ protected function initialise(): void { // Set the current directory. $this->set('cwd', getcwd()); } /** * Renders an error message for a Throwable object * * @param \Throwable $throwable The Throwable object to render the message for. * * @return void * * @since 2.0.0 */ public function renderThrowable(\Throwable $throwable): void { $output = $this->consoleOutput instanceof ConsoleOutputInterface ? $this->consoleOutput->getErrorOutput() : $this->consoleOutput; $output->writeln('', OutputInterface::VERBOSITY_QUIET); $this->doRenderThrowable($throwable, $output); if (null !== $this->runningCommand) { $output->writeln( sprintf( '%s', sprintf($this->runningCommand->getSynopsis(), $this->getName()) ), OutputInterface::VERBOSITY_QUIET ); $output->writeln('', OutputInterface::VERBOSITY_QUIET); } } /** * Handles recursively rendering error messages for a Throwable and all previous Throwables contained within. * * @param \Throwable $throwable The Throwable object to render the message for. * @param OutputInterface $output The output object to send the message to. * * @return void * * @since 2.0.0 */ protected function doRenderThrowable(\Throwable $throwable, OutputInterface $output): void { do { $message = trim($throwable->getMessage()); if ($message === '' || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { $class = \get_class($throwable); if ($class[0] === 'c' && strpos($class, "class@anonymous\0") === 0) { $class = get_parent_class($class) ?: key(class_implements($class)); } $title = sprintf(' [%s%s] ', $class, ($code = $throwable->getCode()) !== 0 ? ' (' . $code . ')' : ''); $len = StringHelper::strlen($title); } else { $len = 0; } if (strpos($message, "class@anonymous\0") !== false) { $message = preg_replace_callback( '/class@anonymous\x00.*?\.php(?:0x?|:[0-9]++\$)[0-9a-fA-F]++/', function ($m) { return class_exists($m[0], false) ? (get_parent_class($m[0]) ?: key(class_implements($m[0]))) . '@anonymous' : $m[0]; }, $message ); } $width = $this->terminal->getWidth() ? $this->terminal->getWidth() - 1 : PHP_INT_MAX; $lines = []; foreach ($message !== '' ? preg_split('/\r?\n/', $message) : [] as $line) { foreach ($this->splitStringByWidth($line, $width - 4) as $line) { // Pre-format lines to get the right string length $lineLength = StringHelper::strlen($line) + 4; $lines[] = [$line, $lineLength]; $len = max($lineLength, $len); } } $messages = []; if (!$throwable instanceof ExceptionInterface || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { $messages[] = sprintf( '%s', OutputFormatter::escape( sprintf( 'In %s line %s:', basename($throwable->getFile()) ?: 'n/a', $throwable->getLine() ?: 'n/a' ) ) ); } $messages[] = $emptyLine = sprintf('%s', str_repeat(' ', $len)); if ($message === '' || OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { $messages[] = sprintf('%s%s', $title, str_repeat(' ', max(0, $len - StringHelper::strlen($title)))); } foreach ($lines as $line) { $messages[] = sprintf(' %s %s', OutputFormatter::escape($line[0]), str_repeat(' ', $len - $line[1])); } $messages[] = $emptyLine; $messages[] = ''; $output->writeln($messages, OutputInterface::VERBOSITY_QUIET); if (OutputInterface::VERBOSITY_VERBOSE <= $output->getVerbosity()) { $output->writeln('Exception trace:', OutputInterface::VERBOSITY_QUIET); // Exception related properties $trace = $throwable->getTrace(); array_unshift( $trace, [ 'function' => '', 'file' => $throwable->getFile() ?: 'n/a', 'line' => $throwable->getLine() ?: 'n/a', 'args' => [], ] ); for ($i = 0, $count = \count($trace); $i < $count; ++$i) { $class = $trace[$i]['class'] ?? ''; $type = $trace[$i]['type'] ?? ''; $function = $trace[$i]['function'] ?? ''; $file = $trace[$i]['file'] ?? 'n/a'; $line = $trace[$i]['line'] ?? 'n/a'; $output->writeln( sprintf( ' %s%s at %s:%s', $class, $function ? $type . $function . '()' : '', $file, $line ), OutputInterface::VERBOSITY_QUIET ); } $output->writeln('', OutputInterface::VERBOSITY_QUIET); } } while ($throwable = $throwable->getPrevious()); } /** * Splits a string for a specified width for use in an output. * * @param string $string The string to split. * @param integer $width The maximum width of the output. * * @return string[] * * @since 2.0.0 */ private function splitStringByWidth(string $string, int $width): array { /* * The str_split function is not suitable for multi-byte characters, we should use preg_split to get char array properly. * Additionally, array_slice() is not enough as some character has doubled width. * We need a function to split string not by character count but by string width */ if (false === $encoding = mb_detect_encoding($string, null, true)) { return str_split($string, $width); } $utf8String = mb_convert_encoding($string, 'utf8', $encoding); $lines = []; $line = ''; $offset = 0; while (preg_match('/.{1,10000}/u', $utf8String, $m, 0, $offset)) { $offset += \strlen($m[0]); foreach (preg_split('//u', $m[0]) as $char) { // Test if $char could be appended to current line if (mb_strwidth($line . $char, 'utf8') <= $width) { $line .= $char; continue; } // If not, push current line to array and make a new line $lines[] = str_pad($line, $width); $line = $char; } } $lines[] = \count($lines) ? str_pad($line, $width) : $line; mb_convert_variables($encoding, 'utf8', $lines); return $lines; } /** * Run the given command. * * @param AbstractCommand $command The command to run. * @param InputInterface $input The input to inject into the command. * @param OutputInterface $output The output to inject into the command. * * @return integer * * @since 2.0.0 * @throws \Throwable */ protected function runCommand(AbstractCommand $command, InputInterface $input, OutputInterface $output): int { if ($command->getHelperSet() !== null) { foreach ($command->getHelperSet() as $helper) { if ($helper instanceof InputAwareInterface) { $helper->setInput($input); } } } // If the application doesn't have an event dispatcher, we can short circuit and just execute the command try { $this->getDispatcher(); } catch (\UnexpectedValueException $exception) { return $command->execute($input, $output); } // Bind before dispatching the event so the listeners have access to input options/arguments try { $command->mergeApplicationDefinition(); $input->bind($command->getDefinition()); } catch (ExceptionInterface $e) { // Ignore invalid options/arguments for now } $event = new BeforeCommandExecuteEvent($this, $command); $exception = null; try { $this->dispatchEvent(ConsoleEvents::BEFORE_COMMAND_EXECUTE, $event); if ($event->isCommandEnabled()) { $exitCode = $command->execute($input, $output); } else { $exitCode = BeforeCommandExecuteEvent::RETURN_CODE_DISABLED; } } catch (\Throwable $exception) { $event = new CommandErrorEvent($exception, $this, $command); $this->dispatchEvent(ConsoleEvents::COMMAND_ERROR, $event); $exception = $event->getError(); $exitCode = $event->getExitCode(); if ($exitCode === 0) { $exception = null; } } $event = new TerminateEvent($exitCode, $this, $command); $this->dispatchEvent(ConsoleEvents::TERMINATE, $event); if ($exception !== null) { throw $exception; } return $event->getExitCode(); } /** * Set whether the application should auto exit. * * @param boolean $autoExit The auto exit state. * * @return void * * @since 2.0.0 */ public function setAutoExit(bool $autoExit): void { $this->autoExit = $autoExit; } /** * Set whether the application should catch Throwables. * * @param boolean $catchThrowables The catch Throwables state. * * @return void * * @since 2.0.0 */ public function setCatchThrowables(bool $catchThrowables): void { $this->catchThrowables = $catchThrowables; } /** * Set the command loader. * * @param Loader\LoaderInterface $loader The new command loader. * * @return void * * @since 2.0.0 */ public function setCommandLoader(Loader\LoaderInterface $loader): void { $this->commandLoader = $loader; } /** * Set the application's helper set. * * @param HelperSet $helperSet The new HelperSet. * * @return void * * @since 2.0.0 */ public function setHelperSet(HelperSet $helperSet): void { $this->helperSet = $helperSet; } /** * Set the name of the application. * * @param string $name The new application name. * * @return void * * @since 2.0.0 */ public function setName(string $name): void { $this->name = $name; } /** * Set the version of the application. * * @param string $version The new application version. * * @return void * * @since 2.0.0 */ public function setVersion(string $version): void { $this->version = $version; } /** * Get the application's auto exit state. * * @return boolean * * @since 2.0.0 */ public function shouldAutoExit(): bool { return $this->autoExit; } /** * Get the application's catch Throwables state. * * @return boolean * * @since 2.0.0 */ public function shouldCatchThrowables(): bool { return $this->catchThrowables; } /** * Returns all namespaces of the command name. * * @param string $name The full name of the command * * @return string[] * * @since 2.0.0 */ private function extractAllNamespaces(string $name): array { // -1 as third argument is needed to skip the command short name when exploding $parts = explode(':', $name, -1); $namespaces = []; foreach ($parts as $part) { if (\count($namespaces)) { $namespaces[] = end($namespaces) . ':' . $part; } else { $namespaces[] = $part; } } return $namespaces; } /** * Returns the namespace part of the command name. * * @param string $name The command name to process * @param integer $limit The maximum number of parts of the namespace * * @return string * * @since 2.0.0 */ private function extractNamespace(string $name, ?int $limit = null): string { $parts = explode(':', $name); array_pop($parts); return implode(':', $limit === null ? $parts : \array_slice($parts, 0, $limit)); } /** * Internal function to initialise the command store, this allows the store to be lazy loaded only when needed. * * @return void * * @since 2.0.0 */ private function initCommands(): void { if ($this->initialised) { return; } $this->initialised = true; foreach ($this->getDefaultCommands() as $command) { $this->addCommand($command); } } }