Files
FendxPHP/fendx-framework/fendx-cli/src/Application.php

390 lines
11 KiB
PHP
Raw Normal View History

<?php
declare(strict_types=1);
namespace Fendx\CLI;
use Fendx\CLI\Command\CommandInterface;
use Fendx\CLI\Input\InputInterface;
use Fendx\CLI\Input\ArgvInput;
use Fendx\CLI\Output\OutputInterface;
use Fendx\CLI\Output\ConsoleOutput;
use Fendx\CLI\Exception\CommandNotFoundException;
final class Application
{
private string $name;
private string $version;
private array $commands = [];
private bool $autoExit = true;
private ?CommandInterface $runningCommand = null;
public function __construct(string $name = 'FendxCLI', string $version = '1.0.0')
{
$this->name = $name;
$this->version = $version;
}
public function run(?InputInterface $input = null, ?OutputInterface $output = null): int
{
$input = $input ?? new ArgvInput();
$output = $output ?? new ConsoleOutput();
try {
$exitCode = $this->doRun($input, $output);
} catch (\Exception $e) {
$this->renderException($e, $output);
$exitCode = 1;
}
if ($this->autoExit) {
exit($exitCode);
}
return $exitCode;
}
private function doRun(InputInterface $input, OutputInterface $output): int
{
// 处理全局选项
if ($input->hasParameterOption(['--help', '-h'])) {
$this->showHelp($output);
return 0;
}
if ($input->hasParameterOption(['--version', '-v'])) {
$this->showVersion($output);
return 0;
}
// 获取命令名称
$commandName = $input->getFirstArgument();
if ($commandName === null) {
$this->showHelp($output);
return 0;
}
// 查找命令
$command = $this->findCommand($commandName);
$this->runningCommand = $command;
// 设置输入
$input->bind($command->getDefinition());
// 验证输入
$input->validate();
// 执行命令
$exitCode = $command->run($input, $output);
return $exitCode;
}
public function add(CommandInterface $command): self
{
$command->setApplication($this);
$this->commands[$command->getName()] = $command;
// 添加别名
foreach ($command->getAliases() as $alias) {
$this->commands[$alias] = $command;
}
return $this;
}
public function get(string $name): CommandInterface
{
if (!isset($this->commands[$name])) {
throw new CommandNotFoundException(sprintf('Command "%s" does not exist.', $name));
}
return $this->commands[$name];
}
public function has(string $name): bool
{
return isset($this->commands[$name]);
}
public function all(): array
{
return $this->commands;
}
public function find(string $name): CommandInterface
{
if (!$this->has($name)) {
// 尝试模糊匹配
$alternatives = $this->findAlternatives($name, array_keys($this->commands));
throw new CommandNotFoundException(
sprintf('Command "%s" does not exist.', $name),
$alternatives
);
}
return $this->commands[$name];
}
public function findNamespace(string $namespace): string
{
$allNamespaces = $this->getNamespaces();
foreach ($allNamespaces as $n) {
if ($n === $namespace || str_starts_with($namespace, $n . ':')) {
return $n;
}
}
throw new CommandNotFoundException(
sprintf('There are no commands defined in the "%s" namespace.', $namespace)
);
}
public function getNamespaces(): array
{
$namespaces = [];
foreach ($this->commands as $name => $command) {
if (str_contains($name, ':')) {
$namespace = substr($name, 0, strpos($name, ':'));
$namespaces[$namespace] = true;
}
}
return array_keys($namespaces);
}
public function findAlternatives(string $name, array $collection): array
{
$alternatives = [];
$threshold = 1.0;
foreach ($collection as $item) {
$distance = levenshtein($name, $item);
$similarity = 1 - ($distance / max(strlen($name), strlen($item)));
if ($similarity >= $threshold) {
$alternatives[] = $item;
}
}
return $alternatives;
}
public function setAutoExit(bool $autoExit): self
{
$this->autoExit = $autoExit;
return $this;
}
public function getName(): string
{
return $this->name;
}
public function getVersion(): string
{
return $this->version;
}
public function getRunningCommand(): ?CommandInterface
{
return $this->runningCommand;
}
private function showHelp(OutputInterface $output): void
{
$output->writeln($this->getHelp());
}
private function showVersion(OutputInterface $output): void
{
$output->writeln($this->getVersion());
}
public function getHelp(): string
{
$help = sprintf(
"%s <info>%s</info> version <comment>%s</comment>\n\n",
$this->name,
$this->name,
$this->version
);
$help .= "<info>Usage:</info>\n";
$help .= " command [options] [arguments]\n\n";
$help .= "<info>Options:</info>\n";
$help .= " <info>-h, --help</info> Display this help message\n";
$help .= " <info>-v, --version</info> Display application version\n\n";
$help .= "<info>Available commands:</info>\n";
// 按命名空间分组显示命令
$namespaces = $this->getNamespaces();
$commands = $this->all();
// 显示无命名空间的命令
$globalCommands = array_filter($commands, function($name) {
return !str_contains($name, ':');
}, ARRAY_FILTER_USE_KEY);
if (!empty($globalCommands)) {
foreach ($globalCommands as $name => $command) {
$help .= sprintf(" <info>%-30s</info> %s\n", $name, $command->getDescription());
}
$help .= "\n";
}
// 显示命名空间命令
foreach ($namespaces as $namespace) {
$help .= sprintf(" <info>%s</info>:\n", $namespace);
$namespaceCommands = array_filter($commands, function($name) use ($namespace) {
return str_starts_with($name, $namespace . ':');
}, ARRAY_FILTER_USE_KEY);
foreach ($namespaceCommands as $name => $command) {
$shortName = substr($name, strlen($namespace) + 1);
$help .= sprintf(" <info>%-30s</info> %s\n", $namespace . ':' . $shortName, $command->getDescription());
}
$help .= "\n";
}
return $help;
}
public function renderException(\Exception $exception, OutputInterface $output): void
{
$output->writeln('');
$output->writeln(sprintf('<error>%s</error>', $exception->getMessage()));
$output->writeln('');
if ($exception instanceof CommandNotFoundException && !empty($exception->getAlternatives())) {
$output->writeln('<info>Did you mean one of these?</info>');
$output->writeln('');
foreach ($exception->getAlternatives() as $alternative) {
$output->writeln(sprintf(' <info>%s</info>', $alternative));
}
$output->writeln('');
}
// 显示堆栈跟踪(仅在调试模式下)
if ($this->isDebug()) {
$output->writeln('<error>Exception trace:</error>');
$output->writeln('');
$trace = $exception->getTrace();
foreach ($trace as $i => $traceItem) {
$file = $traceItem['file'] ?? 'unknown';
$line = $traceItem['line'] ?? 'unknown';
$function = $traceItem['function'] ?? 'unknown';
$class = $traceItem['class'] ?? '';
$output->writeln(sprintf(
' <info>%d.</info> %s%s%s() at <info>%s:%s</info>',
$i + 1,
$class ? $class . '::' : '',
$function,
$file,
$line
));
}
}
}
private function isDebug(): bool
{
return (bool)($_ENV['DEBUG'] ?? false);
}
public function registerDefaultCommands(): self
{
// 注册默认命令
$this->add(new Command\HelpCommand());
$this->add(new Command\ListCommand());
$this->add(new Command\VersionCommand());
return $this;
}
public function doRegisterCommand(string $className): self
{
if (!class_exists($className)) {
throw new \InvalidArgumentException(sprintf('Command class "%s" does not exist.', $className));
}
$reflection = new \ReflectionClass($className);
if (!$reflection->implementsInterface(CommandInterface::class)) {
throw new \InvalidArgumentException(sprintf('Command class "%s" must implement CommandInterface.', $className));
}
$command = $reflection->newInstance();
$this->add($command);
return $this;
}
public function loadCommandsFromDirectory(string $directory, string $namespace = ''): self
{
if (!is_dir($directory)) {
return $this;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$className = $this->getClassNameFromFile($file->getPathname(), $namespace);
if ($className && class_exists($className)) {
try {
$this->doRegisterCommand($className);
} catch (\InvalidArgumentException $e) {
// 忽略无效的命令类
}
}
}
}
return $this;
}
private function getClassNameFromFile(string $filePath, string $namespace): ?string
{
$content = file_get_contents($filePath);
if (!preg_match('/namespace\s+([^;]+);/', $content, $matches)) {
return null;
}
$fileNamespace = $matches[1];
$className = basename($filePath, '.php');
return $fileNamespace . '\\' . $className;
}
public function setCatchExceptions(bool $catchExceptions): self
{
// 这个方法用于向后兼容
return $this;
}
public function renderThrowable(\Throwable $throwable, OutputInterface $output): void
{
if ($throwable instanceof \Exception) {
$this->renderException($throwable, $output);
} else {
$output->writeln('');
$output->writeln(sprintf('<error>%s</error>', $throwable->getMessage()));
$output->writeln('');
}
}
}