mirror of
https://devops.lemonos.cn/lawson/FendxPHP.git
synced 2026-06-15 23:12:49 +08:00
feat(database): 添加用户角色权限系统及相关监控功能
- 创建用户表(users)包含基本信息和认证字段 - 创建角色表(roles)用于权限控制 - 创建权限表(permissions)定义系统权限 - 创建用户角色关联表(user_roles)建立用户与角色关系 - 创建角色权限关联表(role_permissions)建立角色与权限关系 - 创建迁移记录表(migrations)追踪数据库变更 - 添加AdminController提供管理员面板功能 - 实现系统监控、配置管理、缓存清理等功能 - 添加AOP切面编程支持的各种通知类型 - 实现告警管理AlertManager支持多渠道告警 - 添加文档注解接口规范
This commit is contained in:
22
fendx-framework/fendx-cli/composer.json
Normal file
22
fendx-framework/fendx-cli/composer.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "fendx/cli",
|
||||
"description": "FendxPHP CLI Command Line Tools",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "FendxPHP Team",
|
||||
"email": "team@fendx.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=8.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Fendx\\CLI\\": "src/"
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
389
fendx-framework/fendx-cli/src/Application.php
Normal file
389
fendx-framework/fendx-cli/src/Application.php
Normal file
@@ -0,0 +1,389 @@
|
||||
<?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('');
|
||||
}
|
||||
}
|
||||
}
|
||||
352
fendx-framework/fendx-cli/src/Command/Command.php
Normal file
352
fendx-framework/fendx-cli/src/Command/Command.php
Normal file
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputArgument;
|
||||
use Fendx\CLI\Input\InputOption;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
use Fendx\CLI\Application;
|
||||
|
||||
abstract class Command implements CommandInterface
|
||||
{
|
||||
protected ?Application $application = null;
|
||||
protected string $name;
|
||||
protected string $description = '';
|
||||
protected string $help = '';
|
||||
protected array $aliases = [];
|
||||
protected array $usages = [];
|
||||
protected bool $enabled = true;
|
||||
|
||||
public function __construct(string $name = null)
|
||||
{
|
||||
if ($name !== null) {
|
||||
$this->name = $name;
|
||||
} else {
|
||||
$this->name = $this->getDefaultName();
|
||||
}
|
||||
|
||||
if ($this->name === null) {
|
||||
throw new \LogicException(sprintf('The command defined in "%s" cannot have an empty name.', get_class($this)));
|
||||
}
|
||||
|
||||
$this->configure();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
// 子类可以重写此方法来配置命令
|
||||
}
|
||||
|
||||
abstract protected function execute(InputInterface $input, OutputInterface $output): int;
|
||||
|
||||
public function run(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
// 调用初始化方法
|
||||
$this->initialize($input, $output);
|
||||
|
||||
// 验证输入
|
||||
$input->bind($this->getDefinition());
|
||||
$input->validate();
|
||||
|
||||
// 执行命令
|
||||
$exitCode = $this->execute($input, $output);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
protected function initialize(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
// 子类可以重写此方法进行初始化
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function setName(string $name): self
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getHelp(): string
|
||||
{
|
||||
return $this->help ?: $this->getDefaultHelp();
|
||||
}
|
||||
|
||||
public function setHelp(string $help): self
|
||||
{
|
||||
$this->help = $help;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProcessedHelp(): string
|
||||
{
|
||||
$help = $this->getHelp();
|
||||
|
||||
// 处理帮助文本中的占位符
|
||||
$help = str_replace('%command.name%', $this->getName(), $help);
|
||||
$help = str_replace('%command.full_name%', $this->getName(), $help);
|
||||
|
||||
return $help;
|
||||
}
|
||||
|
||||
public function getDefinition(): InputDefinition
|
||||
{
|
||||
return new InputDefinition();
|
||||
}
|
||||
|
||||
public function setApplication(Application $application = null): void
|
||||
{
|
||||
$this->application = $application;
|
||||
}
|
||||
|
||||
public function getApplication(): ?Application
|
||||
{
|
||||
return $this->application;
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->enabled;
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): self
|
||||
{
|
||||
$this->enabled = $enabled;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAliases(): array
|
||||
{
|
||||
return $this->aliases;
|
||||
}
|
||||
|
||||
public function setAliases(array $aliases): self
|
||||
{
|
||||
$this->aliases = $aliases;
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getSynopsis(bool $short = false): string
|
||||
{
|
||||
$definition = $this->getDefinition();
|
||||
$synopsis = $this->name;
|
||||
|
||||
if ($short && $definition->hasArguments()) {
|
||||
$synopsis .= ' [arguments]';
|
||||
} elseif (!$short) {
|
||||
$synopsis .= ' ' . $definition->getSynopsis();
|
||||
}
|
||||
|
||||
return $synopsis;
|
||||
}
|
||||
|
||||
public function addUsage(string $usage): void
|
||||
{
|
||||
if (!in_array($usage, $this->usages)) {
|
||||
$this->usages[] = $usage;
|
||||
}
|
||||
}
|
||||
|
||||
public function getUsages(): array
|
||||
{
|
||||
return $this->usages;
|
||||
}
|
||||
|
||||
protected function getDefaultName(): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function getDefaultHelp(): string
|
||||
{
|
||||
return $this->getDescription();
|
||||
}
|
||||
|
||||
// 辅助方法
|
||||
protected function addArgument(string $name, int $mode = null, string $description = '', $default = null): self
|
||||
{
|
||||
$this->getDefinition()->addArgument(new InputArgument($name, $mode, $description, $default));
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function addOption(string $name, $shortcut = null, int $mode = null, string $description = '', $default = null): self
|
||||
{
|
||||
$this->getDefinition()->addOption(new InputOption($name, $shortcut, $mode, $description, $default));
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function setDefinition(InputDefinition $definition): self
|
||||
{
|
||||
// 这个方法需要子类实现
|
||||
return $this;
|
||||
}
|
||||
|
||||
protected function getApplicationName(): string
|
||||
{
|
||||
return $this->application ? $this->application->getName() : 'Console';
|
||||
}
|
||||
|
||||
// 输出辅助方法
|
||||
protected function writeInfo(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<info>{$message}</info>");
|
||||
}
|
||||
|
||||
protected function writeComment(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<comment>{$message}</comment>");
|
||||
}
|
||||
|
||||
protected function writeQuestion(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<question>{$message}</question>");
|
||||
}
|
||||
|
||||
protected function writeError(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<error>{$message}</error>");
|
||||
}
|
||||
|
||||
protected function writeSuccess(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<info>{$message}</info>");
|
||||
}
|
||||
|
||||
protected function writeWarning(OutputInterface $output, string $message): void
|
||||
{
|
||||
$output->writeln("<comment>{$message}</comment>");
|
||||
}
|
||||
|
||||
// 确认方法
|
||||
protected function confirm(OutputInterface $output, InputInterface $input, string $question, bool $default = true): bool
|
||||
{
|
||||
if ($input->hasParameterOption(['--no-interaction', '-n'])) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$output->write("<question>{$question}</question> ");
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
return $answer === '' ? $default : strtolower($answer[0]) === 'y';
|
||||
}
|
||||
|
||||
// 选择方法
|
||||
protected function ask(OutputInterface $output, InputInterface $input, string $question, $default = null): string
|
||||
{
|
||||
if ($input->hasParameterOption(['--no-interaction', '-n'])) {
|
||||
return (string) $default;
|
||||
}
|
||||
|
||||
$output->write("<question>{$question}</question> ");
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
return $answer === '' ? (string) $default : $answer;
|
||||
}
|
||||
|
||||
// 密码输入方法
|
||||
protected function askHidden(OutputInterface $output, InputInterface $input, string $question): string
|
||||
{
|
||||
if ($input->hasParameterOption(['--no-interaction', '-n'])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$output->write("<question>{$question}</question> ");
|
||||
|
||||
// 隐藏输入
|
||||
system('stty -echo');
|
||||
$password = trim(fgets(STDIN));
|
||||
system('stty echo');
|
||||
$output->writeln('');
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
// 进度条辅助方法
|
||||
protected function startProgress(OutputInterface $output, int $max = 0): void
|
||||
{
|
||||
$output->write('<info>Progress:</info> [');
|
||||
$this->progressCurrent = 0;
|
||||
$this->progressMax = $max;
|
||||
}
|
||||
|
||||
protected function updateProgress(OutputInterface $output, int $current): void
|
||||
{
|
||||
if (!isset($this->progressMax)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$percent = $this->progressMax > 0 ? ($current / $this->progressMax) * 100 : 0;
|
||||
$barLength = 50;
|
||||
$filledLength = (int) (($percent / 100) * $barLength);
|
||||
|
||||
$bar = str_repeat('=', $filledLength) . str_repeat(' ', $barLength - $filledLength);
|
||||
$output->write("\r<info>Progress:</info> [{$bar}] " . number_format($percent, 1) . '%');
|
||||
}
|
||||
|
||||
protected function finishProgress(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln("\r<info>Progress:</info> [=========================================] 100.0%");
|
||||
unset($this->progressCurrent, $this->progressMax);
|
||||
}
|
||||
|
||||
// 表格输出方法
|
||||
protected function renderTable(OutputInterface $output, array $headers, array $rows): void
|
||||
{
|
||||
if (empty($rows)) {
|
||||
$output->writeln('<info>No data to display.</info>');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算列宽
|
||||
$widths = [];
|
||||
foreach ($headers as $i => $header) {
|
||||
$widths[$i] = strlen($header);
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
foreach ($row as $i => $cell) {
|
||||
$widths[$i] = max($widths[$i], strlen((string) $cell));
|
||||
}
|
||||
}
|
||||
|
||||
// 输出表头
|
||||
$headerLine = '|';
|
||||
$separatorLine = '+';
|
||||
foreach ($headers as $i => $header) {
|
||||
$headerLine .= ' ' . str_pad($header, $widths[$i]) . ' |';
|
||||
$separatorLine .= '-' . str_repeat('-', $widths[$i]) . '-+';
|
||||
}
|
||||
|
||||
$output->writeln($separatorLine);
|
||||
$output->writeln($headerLine);
|
||||
$output->writeln($separatorLine);
|
||||
|
||||
// 输出数据行
|
||||
foreach ($rows as $row) {
|
||||
$rowLine = '|';
|
||||
foreach ($row as $i => $cell) {
|
||||
$rowLine .= ' ' . str_pad((string) $cell, $widths[$i]) . ' |';
|
||||
}
|
||||
$output->writeln($rowLine);
|
||||
}
|
||||
|
||||
$output->writeln($separatorLine);
|
||||
}
|
||||
}
|
||||
37
fendx-framework/fendx-cli/src/Command/CommandInterface.php
Normal file
37
fendx-framework/fendx-cli/src/Command/CommandInterface.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
use Fendx\CLI\Application;
|
||||
|
||||
interface CommandInterface
|
||||
{
|
||||
public function getName(): string;
|
||||
|
||||
public function getDescription(): string;
|
||||
|
||||
public function getHelp(): string;
|
||||
|
||||
public function getDefinition(): Input\InputDefinition;
|
||||
|
||||
public function setApplication(Application $application = null): void;
|
||||
|
||||
public function getApplication(): ?Application;
|
||||
|
||||
public function run(InputInterface $input, OutputInterface $output): int;
|
||||
|
||||
public function isEnabled(): bool;
|
||||
|
||||
public function getAliases(): array;
|
||||
|
||||
public function getSynopsis(bool $short = false): string;
|
||||
|
||||
public function addUsage(string $usage): void;
|
||||
|
||||
public function getUsages(): array;
|
||||
|
||||
public function getProcessedHelp(): string;
|
||||
}
|
||||
385
fendx-framework/fendx-cli/src/Command/GenerateCommand.php
Normal file
385
fendx-framework/fendx-cli/src/Command/GenerateCommand.php
Normal file
@@ -0,0 +1,385 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputArgument;
|
||||
use Fendx\CLI\Input\InputOption;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
use Fendx\CLI\Generator\ControllerGenerator;
|
||||
use Fendx\CLI\Generator\ModelGenerator;
|
||||
use Fendx\CLI\Generator\ServiceGenerator;
|
||||
use Fendx\CLI\Generator\TestGenerator;
|
||||
|
||||
class GenerateCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('generate')
|
||||
->setAliases(['gen', 'make'])
|
||||
->setDescription('Generate code files')
|
||||
->setDefinition(new InputDefinition([
|
||||
new InputArgument('type', InputArgument::REQUIRED, 'Type of code to generate (controller, model, service, test)'),
|
||||
new InputArgument('name', InputArgument::REQUIRED, 'Name of the class to generate'),
|
||||
new InputOption('api', null, InputOption::VALUE_NONE, 'Generate API controller'),
|
||||
new InputOption('resource', 'r', InputOption::VALUE_NONE, 'Generate resource controller'),
|
||||
new InputOption('table', 't', InputOption::VALUE_REQUIRED, 'Table name for model'),
|
||||
new InputOption('fields', 'f', InputOption::VALUE_REQUIRED, 'Model fields (name:type:options,...)'),
|
||||
new InputOption('timestamps', null, InputOption::VALUE_NONE, 'Enable timestamps for model'),
|
||||
new InputOption('soft-deletes', null, InputOption::VALUE_NONE, 'Enable soft deletes for model'),
|
||||
new InputOption('migration', 'm', InputOption::VALUE_NONE, 'Create migration for model'),
|
||||
new InputOption('factory', null, InputOption::VALUE_NONE, 'Create factory for model'),
|
||||
new InputOption('seeder', null, InputOption::VALUE_NONE, 'Create seeder for model'),
|
||||
new InputOption('interface', 'i', InputOption::VALUE_NONE, 'Create interface for service'),
|
||||
new InputOption('repository', null, InputOption::VALUE_NONE, 'Create repository for service'),
|
||||
new InputOption('dto', null, InputOption::VALUE_NONE, 'Create DTO for service'),
|
||||
new InputOption('methods', null, InputOption::VALUE_REQUIRED, 'Methods to generate (comma-separated)'),
|
||||
new InputOption('target', null, InputOption::VALUE_REQUIRED, 'Target class for test generation'),
|
||||
new InputOption('namespace', null, InputOption::VALUE_REQUIRED, 'Custom namespace', 'App'),
|
||||
new InputOption('path', null, InputOption::VALUE_REQUIRED, 'Custom path', 'app'),
|
||||
new InputOption('force', null, InputOption::VALUE_NONE, 'Overwrite existing files'),
|
||||
]))
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command generates various types of code files:
|
||||
|
||||
Generate a controller:
|
||||
<info>php %command.full_name% controller UserController</info>
|
||||
|
||||
Generate an API controller:
|
||||
<info>php %command.full_name% controller UserController --api</info>
|
||||
|
||||
Generate a resource controller:
|
||||
<info>php %command.full_name% controller UserController --resource</info>
|
||||
|
||||
Generate a model with fields:
|
||||
<info>php %command.full_name% model User --fields="name:string, email:string:unique, age:int:nullable"</info>
|
||||
|
||||
Generate a model with migration:
|
||||
<info>php %command.full_name% model User --migration</info>
|
||||
|
||||
Generate a service with interface:
|
||||
<info>php %command.full_name% service UserService --interface</info>
|
||||
|
||||
Generate a service with repository:
|
||||
<info>php %command.full_name% service UserService --repository --model=User</info>
|
||||
|
||||
Generate a unit test:
|
||||
<info>php %command.full_name% test UserServiceTest --type=unit --target=UserService</info>
|
||||
|
||||
Generate a feature test:
|
||||
<info>php %command.full_name% test UserControllerTest --type=feature --target=UserController</info>
|
||||
|
||||
Generate an API test:
|
||||
<info>php %command.full_name% test UserControllerTest --type=api --target=UserController</info>
|
||||
|
||||
Custom namespace and path:
|
||||
<info>php %command.full_name% controller Admin/UserController --namespace=Admin --path=admin</info>
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$type = strtolower($input->getArgument('type'));
|
||||
$name = $input->getArgument('name');
|
||||
$namespace = $input->getOption('namespace');
|
||||
$path = $input->getOption('path');
|
||||
|
||||
// 验证类型
|
||||
if (!$this->isValidType($type)) {
|
||||
$output->writeln("<error>Invalid type '{$type}'. Valid types are: controller, model, service, test</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 验证名称
|
||||
if (empty($name)) {
|
||||
$output->writeln("<error>Name cannot be empty.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 准备选项
|
||||
$options = $this->prepareOptions($input, $type);
|
||||
|
||||
try {
|
||||
$success = $this->generateCode($type, $name, $options, $namespace, $path, $output);
|
||||
|
||||
if ($success) {
|
||||
$output->writeln("<info>Code generation completed successfully!</info>");
|
||||
return 0;
|
||||
} else {
|
||||
$output->writeln("<error>Code generation failed.</error>");
|
||||
return 1;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$output->writeln("<error>Error: {$e->getMessage()}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function isValidType(string $type): bool
|
||||
{
|
||||
return in_array($type, ['controller', 'model', 'service', 'test']);
|
||||
}
|
||||
|
||||
private function prepareOptions(InputInterface $input, string $type): array
|
||||
{
|
||||
$options = [];
|
||||
|
||||
switch ($type) {
|
||||
case 'controller':
|
||||
$options['type'] = 'basic';
|
||||
if ($input->getOption('api')) {
|
||||
$options['type'] = 'api';
|
||||
$options['api'] = true;
|
||||
}
|
||||
if ($input->getOption('resource')) {
|
||||
$options['type'] = 'resource';
|
||||
$options['resource'] = true;
|
||||
}
|
||||
if ($input->getOption('methods')) {
|
||||
$options['methods'] = explode(',', $input->getOption('methods'));
|
||||
$options['methods'] = array_map('trim', $options['methods']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
if ($input->getOption('table')) {
|
||||
$options['table'] = $input->getOption('table');
|
||||
}
|
||||
if ($input->getOption('fields')) {
|
||||
$options['fields'] = $input->getOption('fields');
|
||||
}
|
||||
$options['timestamps'] = $input->getOption('timestamps');
|
||||
$options['soft_deletes'] = $input->getOption('soft-deletes');
|
||||
$options['migration'] = $input->getOption('migration');
|
||||
$options['factory'] = $input->getOption('factory');
|
||||
$options['seeder'] = $input->getOption('seeder');
|
||||
break;
|
||||
|
||||
case 'service':
|
||||
$options['type'] = 'basic';
|
||||
if ($input->getOption('repository')) {
|
||||
$options['type'] = 'repository';
|
||||
$options['repository'] = true;
|
||||
}
|
||||
$options['interface'] = $input->getOption('interface');
|
||||
$options['dto'] = $input->getOption('dto');
|
||||
if ($input->getOption('target')) {
|
||||
$options['model'] = $input->getOption('target');
|
||||
}
|
||||
if ($input->getOption('methods')) {
|
||||
$options['methods'] = explode(',', $input->getOption('methods'));
|
||||
$options['methods'] = array_map('trim', $options['methods']);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
$options['type'] = 'basic';
|
||||
if ($input->getOption('target')) {
|
||||
$target = $input->getOption('target');
|
||||
if (str_ends_with($target, 'Controller')) {
|
||||
$options['type'] = 'feature';
|
||||
$options['feature'] = true;
|
||||
} elseif (str_ends_with($target, 'Service')) {
|
||||
$options['type'] = 'unit';
|
||||
}
|
||||
$options['target'] = $target;
|
||||
}
|
||||
if ($input->getOption('api')) {
|
||||
$options['type'] = 'api';
|
||||
$options['api'] = true;
|
||||
}
|
||||
if ($input->getOption('methods')) {
|
||||
$options['methods'] = explode(',', $input->getOption('methods'));
|
||||
$options['methods'] = array_map('trim', $options['methods']);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
$options['force'] = $input->getOption('force');
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function generateCode(string $type, string $name, array $options, string $namespace, string $path, OutputInterface $output): bool
|
||||
{
|
||||
switch ($type) {
|
||||
case 'controller':
|
||||
return $this->generateController($name, $options, $namespace, $path, $output);
|
||||
|
||||
case 'model':
|
||||
return $this->generateModel($name, $options, $namespace, $path, $output);
|
||||
|
||||
case 'service':
|
||||
return $this->generateService($name, $options, $namespace, $path, $output);
|
||||
|
||||
case 'test':
|
||||
return $this->generateTest($name, $options, $namespace, $path, $output);
|
||||
|
||||
default:
|
||||
$output->writeln("<error>Unknown type: {$type}</error>");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function generateController(string $name, array $options, string $namespace, string $path, OutputInterface $output): bool
|
||||
{
|
||||
$generator = new ControllerGenerator($output, $namespace, $path);
|
||||
return $generator->generate($name, $options);
|
||||
}
|
||||
|
||||
private function generateModel(string $name, array $options, string $namespace, string $path, OutputInterface $output): bool
|
||||
{
|
||||
$generator = new ModelGenerator($output, $namespace, $path);
|
||||
return $generator->generate($name, $options);
|
||||
}
|
||||
|
||||
private function generateService(string $name, array $options, string $namespace, string $path, OutputInterface $output): bool
|
||||
{
|
||||
$generator = new ServiceGenerator($output, $namespace, $path);
|
||||
return $generator->generate($name, $options);
|
||||
}
|
||||
|
||||
private function generateTest(string $name, array $options, string $namespace, string $path, OutputInterface $output): bool
|
||||
{
|
||||
$generator = new TestGenerator($output, $namespace, $path);
|
||||
return $generator->generate($name, $options);
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
private function showExamples(OutputInterface $output): void
|
||||
{
|
||||
$output->writeln('<comment>Examples:</comment>');
|
||||
$output->writeln('');
|
||||
$output->writeln(' <info>Controller:</info>');
|
||||
$output->writeln(' php fendx-cli generate controller UserController');
|
||||
$output->writeln(' php fendx-cli generate controller UserController --api');
|
||||
$output->writeln(' php fendx-cli generate controller UserController --resource');
|
||||
$output->writeln('');
|
||||
$output->writeln(' <info>Model:</info>');
|
||||
$output->writeln(' php fendx-cli generate model User');
|
||||
$output->writeln(' php fendx-cli generate model User --fields="name:string, email:string:unique"');
|
||||
$output->writeln(' php fendx-cli generate model User --migration --factory');
|
||||
$output->writeln('');
|
||||
$output->writeln(' <info>Service:</info>');
|
||||
$output->writeln(' php fendx-cli generate service UserService');
|
||||
$output->writeln(' php fendx-cli generate service UserService --interface');
|
||||
$output->writeln(' php fendx-cli generate service UserService --repository');
|
||||
$output->writeln('');
|
||||
$output->writeln(' <info>Test:</info>');
|
||||
$output->writeln(' php fendx-cli generate test UserServiceTest --type=unit --target=UserService');
|
||||
$output->writeln(' php fendx-cli generate test UserControllerTest --type=feature --target=UserController');
|
||||
$output->writeln(' php fendx-cli generate test UserControllerTest --type=api --target=UserController');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$input->getArgument('type')) {
|
||||
$type = $this->choice($output, 'What type of code do you want to generate?', [
|
||||
'controller' => 'Controller class',
|
||||
'model' => 'Model class',
|
||||
'service' => 'Service class',
|
||||
'test' => 'Test class'
|
||||
]);
|
||||
$input->setArgument('type', $type);
|
||||
}
|
||||
|
||||
if (!$input->getArgument('name')) {
|
||||
$name = $this->ask($output, 'What is the name of the class?');
|
||||
$input->setArgument('name', $name);
|
||||
}
|
||||
|
||||
// 根据类型询问特定选项
|
||||
$type = $input->getArgument('type');
|
||||
|
||||
switch ($type) {
|
||||
case 'controller':
|
||||
$this->interactController($input, $output);
|
||||
break;
|
||||
case 'model':
|
||||
$this->interactModel($input, $output);
|
||||
break;
|
||||
case 'service':
|
||||
$this->interactService($input, $output);
|
||||
break;
|
||||
case 'test':
|
||||
$this->interactTest($input, $output);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function interactController(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$input->getOption('api') && !$input->getOption('resource')) {
|
||||
$controllerType = $this->choice($output, 'What type of controller?', [
|
||||
'basic' => 'Basic controller',
|
||||
'api' => 'API controller',
|
||||
'resource' => 'Resource controller'
|
||||
], 'basic');
|
||||
|
||||
if ($controllerType === 'api') {
|
||||
$input->setOption('api', true);
|
||||
} elseif ($controllerType === 'resource') {
|
||||
$input->setOption('resource', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function interactModel(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$input->getOption('fields')) {
|
||||
$fields = $this->ask($output, 'Enter model fields (name:type:options,...):');
|
||||
if ($fields) {
|
||||
$input->setOption('fields', $fields);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$input->getOption('migration') && $this->confirm($output, 'Create migration?', true)) {
|
||||
$input->setOption('migration', true);
|
||||
}
|
||||
|
||||
if (!$input->getOption('factory') && $this->confirm($output, 'Create factory?', false)) {
|
||||
$input->setOption('factory', true);
|
||||
}
|
||||
}
|
||||
|
||||
private function interactService(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$input->getOption('interface') && $this->confirm($output, 'Create interface?', true)) {
|
||||
$input->setOption('interface', true);
|
||||
}
|
||||
|
||||
if (!$input->getOption('repository') && $this->confirm($output, 'Create repository?', false)) {
|
||||
$input->setOption('repository', true);
|
||||
}
|
||||
|
||||
if (!$input->getOption('target')) {
|
||||
$target = $this->ask($output, 'Enter target model name (optional):');
|
||||
if ($target) {
|
||||
$input->setOption('target', $target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function interactTest(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
if (!$input->getOption('target')) {
|
||||
$target = $this->ask($output, 'Enter target class name:');
|
||||
$input->setOption('target', $target);
|
||||
}
|
||||
|
||||
if (!$input->getOption('api') && str_ends_with($input->getOption('target'), 'Controller')) {
|
||||
$testType = $this->choice($output, 'What type of test?', [
|
||||
'feature' => 'Feature test',
|
||||
'api' => 'API test'
|
||||
], 'feature');
|
||||
|
||||
if ($testType === 'api') {
|
||||
$input->setOption('api', true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
fendx-framework/fendx-cli/src/Command/HelpCommand.php
Normal file
49
fendx-framework/fendx-cli/src/Command/HelpCommand.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputArgument;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
class HelpCommand extends Command
|
||||
{
|
||||
private ?CommandInterface $command = null;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('help')
|
||||
->setDescription('Display help for a command')
|
||||
->setDefinition(new InputDefinition([
|
||||
new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name', 'help'),
|
||||
new InputArgument('format', InputArgument::OPTIONAL, 'The output format (txt, xml, json)', 'txt'),
|
||||
]))
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command displays help for a given command:
|
||||
|
||||
<info>php %command.full_name% list</info>
|
||||
|
||||
To display the list of available commands, please use the <info>list</info> command.
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
public function setCommand(CommandInterface $command): void
|
||||
{
|
||||
$this->command = $command;
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
if ($this->command === null) {
|
||||
$this->command = $this->getApplication()->get($input->getArgument('command_name'));
|
||||
}
|
||||
|
||||
$output->writeln($this->command->getProcessedHelp());
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
111
fendx-framework/fendx-cli/src/Command/ListCommand.php
Normal file
111
fendx-framework/fendx-cli/src/Command/ListCommand.php
Normal file
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputOption;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
class ListCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('list')
|
||||
->setAliases(['ls'])
|
||||
->setDescription('Lists commands')
|
||||
->setDefinition(new InputDefinition([
|
||||
new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'),
|
||||
new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json)', 'txt'),
|
||||
]))
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command lists all commands:
|
||||
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
You can also display the commands for a specific namespace:
|
||||
|
||||
<info>php %command.full_name% test</info>
|
||||
|
||||
You can also output the information in other formats by using the <comment>--format</comment> option:
|
||||
|
||||
<info>php %command.full_name% --format=xml</info>
|
||||
|
||||
It's also possible to get raw list of commands (useful for embedding command runner):
|
||||
|
||||
<info>php %command.full_name% --raw</info>
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$application = $this->getApplication();
|
||||
$commands = $application->all();
|
||||
$raw = $input->getOption('raw');
|
||||
$format = $input->getOption('format');
|
||||
|
||||
if ($raw) {
|
||||
$output->writeln(implode("\n", array_keys($commands)));
|
||||
return 0;
|
||||
}
|
||||
|
||||
$width = $this->getMaxWidth($commands);
|
||||
|
||||
$output->writeln($application->getHelp());
|
||||
$output->writeln('');
|
||||
|
||||
// 按命名空间分组
|
||||
$namespaces = $application->getNamespaces();
|
||||
|
||||
// 显示无命名空间的命令
|
||||
$globalCommands = array_filter($commands, function($name) {
|
||||
return !str_contains($name, ':');
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
if (!empty($globalCommands)) {
|
||||
$output->writeln('<comment>Available commands:</comment>');
|
||||
$output->writeln('');
|
||||
|
||||
foreach ($globalCommands as $name => $command) {
|
||||
if ($command->isEnabled()) {
|
||||
$output->writeln(sprintf(' <info>%-{$width}s</info> %s', $name, $command->getDescription()));
|
||||
}
|
||||
}
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
// 显示命名空间命令
|
||||
foreach ($namespaces as $namespace) {
|
||||
$output->writeln("<comment>{$namespace}:</comment>");
|
||||
|
||||
$namespaceCommands = array_filter($commands, function($name) use ($namespace) {
|
||||
return str_starts_with($name, $namespace . ':');
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
foreach ($namespaceCommands as $name => $command) {
|
||||
if ($command->isEnabled()) {
|
||||
$shortName = substr($name, strlen($namespace) + 1);
|
||||
$output->writeln(sprintf(' <info>%-{$width}s</info> %s', $namespace . ':' . $shortName, $command->getDescription()));
|
||||
}
|
||||
}
|
||||
$output->writeln('');
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getMaxWidth(array $commands): int
|
||||
{
|
||||
$maxWidth = 0;
|
||||
foreach ($commands as $command) {
|
||||
if ($command->isEnabled()) {
|
||||
$maxWidth = max($maxWidth, strlen($command->getName()));
|
||||
}
|
||||
}
|
||||
|
||||
return $maxWidth + 2;
|
||||
}
|
||||
}
|
||||
351
fendx-framework/fendx-cli/src/Command/MigrateCommand.php
Normal file
351
fendx-framework/fendx-cli/src/Command/MigrateCommand.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputArgument;
|
||||
use Fendx\CLI\Input\InputOption;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
class MigrateCommand extends Command
|
||||
{
|
||||
private string $migrationsPath;
|
||||
private string $databasePath;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('migrate')
|
||||
->setDescription('Run database migrations')
|
||||
->setDefinition(new InputDefinition([
|
||||
new InputArgument('action', InputArgument::REQUIRED, 'Migration action (run, rollback, status, create)'),
|
||||
new InputArgument('name', InputArgument::OPTIONAL, 'Migration name (for create action)'),
|
||||
new InputOption('path', 'p', InputOption::VALUE_REQUIRED, 'Migrations path', 'database/migrations'),
|
||||
new InputOption('database', 'd', InputOption::VALUE_REQUIRED, 'Database configuration path', 'config/database.php'),
|
||||
new InputOption('force', 'f', InputOption::VALUE_NONE, 'Force operation in production'),
|
||||
new InputOption('step', 's', InputOption::VALUE_REQUIRED, 'Number of steps to rollback', '1'),
|
||||
new InputOption('batch', 'b', InputOption::VALUE_REQUIRED, 'Batch number for rollback', '0'),
|
||||
]))
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command manages database migrations:
|
||||
|
||||
Run all pending migrations:
|
||||
<info>php %command.full_name% run</info>
|
||||
|
||||
Rollback last migration:
|
||||
<info>php %command.full_name% rollback</info>
|
||||
|
||||
Rollback multiple migrations:
|
||||
<info>php %command.full_name% rollback --step=3</info>
|
||||
|
||||
Show migration status:
|
||||
<info>php %command.full_name% status</info>
|
||||
|
||||
Create new migration:
|
||||
<info>php %command.full_name% create create_users_table</info>
|
||||
|
||||
Specify custom migrations path:
|
||||
<info>php %command.full_name% run --path=custom/migrations</info>
|
||||
|
||||
Force operation in production:
|
||||
<info>php %command.full_name% run --force</info>
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$this->migrationsPath = $input->getOption('path');
|
||||
$this->databasePath = $input->getOption('database');
|
||||
|
||||
$action = $input->getArgument('action');
|
||||
|
||||
switch ($action) {
|
||||
case 'run':
|
||||
return $this->runMigrations($input, $output);
|
||||
|
||||
case 'rollback':
|
||||
return $this->rollbackMigrations($input, $output);
|
||||
|
||||
case 'status':
|
||||
return $this->showStatus($output);
|
||||
|
||||
case 'create':
|
||||
return $this->createMigration($input, $output);
|
||||
|
||||
default:
|
||||
$output->writeln("<error>Invalid action: {$action}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function runMigrations(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('<info>Running database migrations...</info>');
|
||||
|
||||
// 检查是否在生产环境
|
||||
if ($this->isProduction() && !$input->getOption('force')) {
|
||||
$output->writeln('<error>Cannot run migrations in production. Use --force to override.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 创建迁移表
|
||||
$this->createMigrationsTable($output);
|
||||
|
||||
// 获取待执行的迁移
|
||||
$pendingMigrations = $this->getPendingMigrations();
|
||||
|
||||
if (empty($pendingMigrations)) {
|
||||
$output->writeln('<info>No pending migrations.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Found ' . count($pendingMigrations) . ' pending migrations.</info>');
|
||||
|
||||
// 执行迁移
|
||||
$batch = $this->getNextBatchNumber();
|
||||
foreach ($pendingMigrations as $migration) {
|
||||
$output->writeln("<comment>Running: {$migration}</comment>");
|
||||
|
||||
if ($this->executeMigration($migration, $output)) {
|
||||
$this->logMigration($migration, $batch);
|
||||
$output->writeln("<info>Migrated: {$migration}</info>");
|
||||
} else {
|
||||
$output->writeln("<error>Failed to migrate: {$migration}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('<info>All migrations completed successfully.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function rollbackMigrations(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('<info>Rolling back migrations...</info>');
|
||||
|
||||
$step = (int) $input->getOption('step');
|
||||
$batch = (int) $input->getOption('batch');
|
||||
|
||||
if ($this->isProduction() && !$input->getOption('force')) {
|
||||
$output->writeln('<error>Cannot rollback migrations in production. Use --force to override.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 获取要回滚的迁移
|
||||
$migrationsToRollback = $this->getMigrationsToRollback($step, $batch);
|
||||
|
||||
if (empty($migrationsToRollback)) {
|
||||
$output->writeln('<info>No migrations to rollback.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
$output->writeln('<info>Found ' . count($migrationsToRollback) . ' migrations to rollback.</info>');
|
||||
|
||||
// 回滚迁移
|
||||
foreach ($migrationsToRollback as $migration) {
|
||||
$output->writeln("<comment>Rolling back: {$migration['migration']}</comment>");
|
||||
|
||||
if ($this->rollbackMigration($migration['migration'], $output)) {
|
||||
$this->removeMigrationLog($migration['id']);
|
||||
$output->writeln("<info>Rolled back: {$migration['migration']}</info>");
|
||||
} else {
|
||||
$output->writeln("<error>Failed to rollback: {$migration['migration']}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('<info>Rollback completed successfully.</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function showStatus(OutputInterface $output): int
|
||||
{
|
||||
$output->writeln('<info>Migration status:</info>');
|
||||
$output->writeln('');
|
||||
|
||||
// 获取所有迁移文件
|
||||
$allMigrations = $this->getAllMigrations();
|
||||
|
||||
// 获取已执行的迁移
|
||||
$ranMigrations = $this->getRanMigrations();
|
||||
|
||||
$output->writeln('<comment>+----------------+----------------+----------------+</comment>');
|
||||
$output->writeln('<comment>| Migration | Batch | Ran At |</comment>');
|
||||
$output->writeln('<comment>+----------------+----------------+----------------+</comment>');
|
||||
|
||||
foreach ($allMigrations as $migration) {
|
||||
$ran = $ranMigrations[$migration] ?? null;
|
||||
|
||||
if ($ran) {
|
||||
$output->writeln(sprintf('| %-14s | %-14s | %-14s |',
|
||||
$migration,
|
||||
$ran['batch'],
|
||||
$ran['ran_at']
|
||||
));
|
||||
} else {
|
||||
$output->writeln(sprintf('| %-14s | %-14s | %-14s |',
|
||||
$migration,
|
||||
'<error>Pending</error>',
|
||||
'<error>Not Run</error>'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
$output->writeln('<comment>+----------------+----------------+----------------+</comment>');
|
||||
|
||||
$pendingCount = count($allMigrations) - count($ranMigrations);
|
||||
$output->writeln('');
|
||||
$output->writeln("<info>Total migrations: " . count($allMigrations) . "</info>");
|
||||
$output->writeln("<info>Ran migrations: " . count($ranMigrations) . "</info>");
|
||||
$output->writeln("<info>Pending migrations: {$pendingCount}</info>");
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function createMigration(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$name = $input->getArgument('name');
|
||||
|
||||
if (!$name) {
|
||||
$output->writeln('<error>Migration name is required for create action.</error>');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 确保迁移目录存在
|
||||
if (!is_dir($this->migrationsPath)) {
|
||||
mkdir($this->migrationsPath, 0755, true);
|
||||
}
|
||||
|
||||
// 生成迁移文件名
|
||||
$timestamp = date('Y_m_d_His');
|
||||
$filename = $timestamp . '_' . $name . '.php';
|
||||
$filepath = $this->migrationsPath . '/' . $filename;
|
||||
|
||||
// 生成迁移内容
|
||||
$className = $this->generateClassName($name);
|
||||
$content = $this->generateMigrationContent($className);
|
||||
|
||||
// 写入文件
|
||||
if (file_put_contents($filepath, $content)) {
|
||||
$output->writeln("<info>Created migration: {$filename}</info>");
|
||||
return 0;
|
||||
} else {
|
||||
$output->writeln("<error>Failed to create migration: {$filename}</error>");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private function createMigrationsTable(OutputInterface $output): void
|
||||
{
|
||||
// 这里应该创建migrations表
|
||||
// 简化实现,实际应该使用数据库连接
|
||||
}
|
||||
|
||||
private function getPendingMigrations(): array
|
||||
{
|
||||
// 获取所有迁移文件
|
||||
$allMigrations = $this->getAllMigrations();
|
||||
|
||||
// 获取已执行的迁移
|
||||
$ranMigrations = $this->getRanMigrations();
|
||||
|
||||
// 返回未执行的迁移
|
||||
return array_diff($allMigrations, array_keys($ranMigrations));
|
||||
}
|
||||
|
||||
private function getAllMigrations(): array
|
||||
{
|
||||
if (!is_dir($this->migrationsPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$files = glob($this->migrationsPath . '/*.php');
|
||||
$migrations = [];
|
||||
|
||||
foreach ($files as $file) {
|
||||
$migrations[] = basename($file, '.php');
|
||||
}
|
||||
|
||||
sort($migrations);
|
||||
return $migrations;
|
||||
}
|
||||
|
||||
private function getRanMigrations(): array
|
||||
{
|
||||
// 这里应该从数据库获取已执行的迁移
|
||||
// 简化实现,返回空数组
|
||||
return [];
|
||||
}
|
||||
|
||||
private function executeMigration(string $migration, OutputInterface $output): bool
|
||||
{
|
||||
// 这里应该执行迁移文件
|
||||
// 简化实现,返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
private function rollbackMigration(string $migration, OutputInterface $output): bool
|
||||
{
|
||||
// 这里应该回滚迁移
|
||||
// 简化实现,返回true
|
||||
return true;
|
||||
}
|
||||
|
||||
private function logMigration(string $migration, int $batch): void
|
||||
{
|
||||
// 这里应该记录迁移到数据库
|
||||
}
|
||||
|
||||
private function removeMigrationLog(int $id): void
|
||||
{
|
||||
// 这里应该从数据库删除迁移记录
|
||||
}
|
||||
|
||||
private function getMigrationsToRollback(int $step, int $batch): array
|
||||
{
|
||||
// 这里应该获取要回滚的迁移
|
||||
// 简化实现,返回空数组
|
||||
return [];
|
||||
}
|
||||
|
||||
private function getNextBatchNumber(): int
|
||||
{
|
||||
// 这里应该获取下一个批次号
|
||||
return 1;
|
||||
}
|
||||
|
||||
private function generateClassName(string $name): string
|
||||
{
|
||||
return str_replace('_', '', ucwords($name, '_'));
|
||||
}
|
||||
|
||||
private function generateMigrationContent(string $className): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
|
||||
use Fendx\Database\Migration;
|
||||
|
||||
class {$className} extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Add your migration logic here
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Add your rollback logic here
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function isProduction(): bool
|
||||
{
|
||||
return ($_ENV['APP_ENV'] ?? 'development') === 'production';
|
||||
}
|
||||
}
|
||||
167
fendx-framework/fendx-cli/src/Command/ServerCommand.php
Normal file
167
fendx-framework/fendx-cli/src/Command/ServerCommand.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Input\InputArgument;
|
||||
use Fendx\CLI\Input\InputOption;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
class ServerCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('server')
|
||||
->setDescription('Start the development server')
|
||||
->setDefinition(new InputDefinition([
|
||||
new InputArgument('host', InputArgument::OPTIONAL, 'The host to bind to', '127.0.0.1'),
|
||||
new InputArgument('port', InputArgument::OPTIONAL, 'The port to bind to', '8000'),
|
||||
new InputOption('docroot', 'd', InputOption::VALUE_REQUIRED, 'Document root directory', 'public'),
|
||||
new InputOption('router', 'r', InputOption::VALUE_REQUIRED, 'Router script path', null),
|
||||
new InputOption('workers', 'w', InputOption::VALUE_REQUIRED, 'Number of worker processes', '4'),
|
||||
new InputOption('daemon', null, InputOption::VALUE_NONE, 'Run in daemon mode'),
|
||||
new InputOption('pid', null, InputOption::VALUE_REQUIRED, 'PID file path', null),
|
||||
]))
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command starts the PHP development server:
|
||||
|
||||
<info>php %command.full_name%</info>
|
||||
|
||||
Start server on specific host and port:
|
||||
<info>php %command.full_name% 0.0.0.0 8080</info>
|
||||
|
||||
Specify custom document root:
|
||||
<info>php %command.full_name% --docroot=web</info>
|
||||
|
||||
Use custom router script:
|
||||
<info>php %command.full_name% --router=router.php</info>
|
||||
|
||||
Run in daemon mode:
|
||||
<info>php %command.full_name% --daemon</info>
|
||||
|
||||
Multiple workers for better performance:
|
||||
<info>php %command.full_name% --workers=8</info>
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$host = $input->getArgument('host');
|
||||
$port = $input->getArgument('port');
|
||||
$docroot = $input->getOption('docroot');
|
||||
$router = $input->getOption('router');
|
||||
$workers = (int) $input->getOption('workers');
|
||||
$daemon = $input->getOption('daemon');
|
||||
$pidFile = $input->getOption('pid');
|
||||
|
||||
// 验证文档根目录
|
||||
if (!is_dir($docroot)) {
|
||||
$output->writeln("<error>Document root '{$docroot}' does not exist.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 验证路由脚本
|
||||
if ($router && !file_exists($router)) {
|
||||
$output->writeln("<error>Router script '{$router}' does not exist.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 检查端口是否被占用
|
||||
if ($this->isPortInUse($port)) {
|
||||
$output->writeln("<error>Port {$port} is already in use.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 构建服务器命令
|
||||
$command = $this->buildServerCommand($host, $port, $docroot, $router);
|
||||
|
||||
if ($daemon) {
|
||||
return $this->startDaemon($command, $output, $pidFile);
|
||||
} else {
|
||||
return $this->startServer($command, $output, $workers);
|
||||
}
|
||||
}
|
||||
|
||||
private function buildServerCommand(string $host, string $port, string $docroot, ?string $router): string
|
||||
{
|
||||
$command = sprintf('php -S %s:%d -t %s', $host, $port, $docroot);
|
||||
|
||||
if ($router) {
|
||||
$command .= ' ' . $router;
|
||||
}
|
||||
|
||||
return $command;
|
||||
}
|
||||
|
||||
private function startServer(string $command, OutputInterface $output, int $workers): int
|
||||
{
|
||||
$output->writeln("<info>Starting development server...</info>");
|
||||
$output->writeln("<comment>Command: {$command}</comment>");
|
||||
$output->writeln("<info>Press Ctrl+C to stop the server.</info>");
|
||||
$output->writeln('');
|
||||
|
||||
if ($workers > 1) {
|
||||
$output->writeln("<info>Starting {$workers} worker processes...</info>");
|
||||
// 这里可以实现多进程支持
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
passthru($command, $exitCode);
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
private function startDaemon(string $command, OutputInterface $output, ?string $pidFile): int
|
||||
{
|
||||
$output->writeln("<info>Starting server in daemon mode...</info>");
|
||||
|
||||
$pid = pcntl_fork();
|
||||
|
||||
if ($pid == -1) {
|
||||
$output->writeln("<error>Could not fork process.</error>");
|
||||
return 1;
|
||||
} elseif ($pid) {
|
||||
// 父进程
|
||||
if ($pidFile) {
|
||||
file_put_contents($pidFile, $pid);
|
||||
$output->writeln("<info>PID file written to: {$pidFile}</info>");
|
||||
}
|
||||
|
||||
$output->writeln("<info>Server started with PID: {$pid}</info>");
|
||||
return 0;
|
||||
} else {
|
||||
// 子进程
|
||||
// 成为会话组长
|
||||
if (posix_setsid() == -1) {
|
||||
$output->writeln("<error>Could not setsid.</error>");
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 重定向标准输入输出
|
||||
fclose(STDIN);
|
||||
fclose(STDOUT);
|
||||
fclose(STDERR);
|
||||
|
||||
// 执行服务器命令
|
||||
exec($command);
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private function isPortInUse(int $port): bool
|
||||
{
|
||||
$socket = @fsockopen('127.0.0.1', $port, $errno, $errstr, 1);
|
||||
|
||||
if ($socket) {
|
||||
fclose($socket);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
33
fendx-framework/fendx-cli/src/Command/VersionCommand.php
Normal file
33
fendx-framework/fendx-cli/src/Command/VersionCommand.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Command;
|
||||
|
||||
use Fendx\CLI\Input\InputInterface;
|
||||
use Fendx\CLI\Input\InputDefinition;
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
class VersionCommand extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName('version')
|
||||
->setAliases(['ver', '-v'])
|
||||
->setDescription('Displays application version')
|
||||
->setHelp(<<<'EOF'
|
||||
The <info>%command.name%</info> command displays the current application version:
|
||||
|
||||
<info>php %command.full_name%</info>
|
||||
EOF
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$application = $this->getApplication();
|
||||
$output->writeln($application->getName() . ' <info>' . $application->getVersion() . '</info>');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
335
fendx-framework/fendx-cli/src/Generator/CodeGenerator.php
Normal file
335
fendx-framework/fendx-cli/src/Generator/CodeGenerator.php
Normal file
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Generator;
|
||||
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
|
||||
abstract class CodeGenerator
|
||||
{
|
||||
protected OutputInterface $output;
|
||||
protected string $namespace;
|
||||
protected string $basePath;
|
||||
protected array $templates = [];
|
||||
|
||||
public function __construct(OutputInterface $output, string $namespace = 'App', string $basePath = 'app')
|
||||
{
|
||||
$this->output = $output;
|
||||
$this->namespace = $namespace;
|
||||
$this->basePath = $basePath;
|
||||
$this->loadTemplates();
|
||||
}
|
||||
|
||||
abstract public function generate(string $name, array $options = []): bool;
|
||||
|
||||
protected function loadTemplates(): void
|
||||
{
|
||||
// 子类可以重写此方法加载模板
|
||||
}
|
||||
|
||||
protected function renderTemplate(string $template, array $variables = []): string
|
||||
{
|
||||
if (!isset($this->templates[$template])) {
|
||||
throw new \InvalidArgumentException("Template '{$template}' not found.");
|
||||
}
|
||||
|
||||
$content = $this->templates[$template];
|
||||
|
||||
// 替换变量
|
||||
foreach ($variables as $key => $value) {
|
||||
$content = str_replace('{{' . $key . '}}', $value, $content);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
protected function createDirectory(string $path): bool
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
if (!mkdir($path, 0755, true)) {
|
||||
$this->output->writeln("<error>Failed to create directory: {$path}</error>");
|
||||
return false;
|
||||
}
|
||||
$this->output->writeln("<info>Created directory: {$path}</info>");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function createFile(string $path, string $content): bool
|
||||
{
|
||||
if (file_exists($path)) {
|
||||
$this->output->writeln("<error>File already exists: {$path}</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !$this->createDirectory($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file_put_contents($path, $content) === false) {
|
||||
$this->output->writeln("<error>Failed to create file: {$path}</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->output->writeln("<info>Created file: {$path}</info>");
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function getClassName(string $name): string
|
||||
{
|
||||
return str_replace(['-', '_'], '', ucwords($name, '-_'));
|
||||
}
|
||||
|
||||
protected function getTableName(string $name): string
|
||||
{
|
||||
return strtolower(preg_replace('/([A-Z])/', '_$1', $name));
|
||||
}
|
||||
|
||||
protected function getVariableName(string $name): string
|
||||
{
|
||||
return lcfirst($this->getClassName($name));
|
||||
}
|
||||
|
||||
protected function getPluralName(string $name): string
|
||||
{
|
||||
$last = strtolower(substr($name, -1));
|
||||
if ($last === 'y') {
|
||||
return substr($name, 0, -1) . 'ies';
|
||||
} elseif (in_array($last, ['s', 'x', 'z'])) {
|
||||
return $name . 'es';
|
||||
} else {
|
||||
return $name . 's';
|
||||
}
|
||||
}
|
||||
|
||||
protected function validateName(string $name): bool
|
||||
{
|
||||
if (empty($name)) {
|
||||
$this->output->writeln("<error>Name cannot be empty.</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!preg_match('/^[a-zA-Z][a-zA-Z0-9_-]*$/', $name)) {
|
||||
$this->output->writeln("<error>Invalid name. Name must start with a letter and contain only letters, numbers, hyphens, and underscores.</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function parseFields(string $fields): array
|
||||
{
|
||||
$fieldDefinitions = [];
|
||||
$lines = explode(',', $fields);
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (empty($line)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $line);
|
||||
if (count($parts) < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = trim($parts[0]);
|
||||
$type = trim($parts[1]);
|
||||
$options = [];
|
||||
|
||||
if (isset($parts[2])) {
|
||||
$optionParts = explode('|', $parts[2]);
|
||||
foreach ($optionParts as $option) {
|
||||
$option = trim($option);
|
||||
if ($option === 'nullable') {
|
||||
$options['nullable'] = true;
|
||||
} elseif ($option === 'unique') {
|
||||
$options['unique'] = true;
|
||||
} elseif (str_starts_with($option, 'default:')) {
|
||||
$options['default'] = substr($option, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$fieldDefinitions[] = [
|
||||
'name' => $name,
|
||||
'type' => $type,
|
||||
'options' => $options
|
||||
];
|
||||
}
|
||||
|
||||
return $fieldDefinitions;
|
||||
}
|
||||
|
||||
protected function getPhpType(string $dbType): string
|
||||
{
|
||||
$typeMap = [
|
||||
'int' => 'int',
|
||||
'integer' => 'int',
|
||||
'bigint' => 'int',
|
||||
'smallint' => 'int',
|
||||
'tinyint' => 'int',
|
||||
'varchar' => 'string',
|
||||
'char' => 'string',
|
||||
'text' => 'string',
|
||||
'longtext' => 'string',
|
||||
'mediumtext' => 'string',
|
||||
'float' => 'float',
|
||||
'double' => 'float',
|
||||
'decimal' => 'string',
|
||||
'bool' => 'bool',
|
||||
'boolean' => 'bool',
|
||||
'date' => 'string',
|
||||
'datetime' => 'string',
|
||||
'timestamp' => 'int',
|
||||
'json' => 'array'
|
||||
];
|
||||
|
||||
return $typeMap[$dbType] ?? 'string';
|
||||
}
|
||||
|
||||
protected function getCasterType(string $phpType): string
|
||||
{
|
||||
$casterMap = [
|
||||
'int' => 'int',
|
||||
'float' => 'float',
|
||||
'bool' => 'bool',
|
||||
'array' => 'array',
|
||||
'string' => 'string'
|
||||
];
|
||||
|
||||
return $casterMap[$phpType] ?? 'string';
|
||||
}
|
||||
|
||||
protected function getValidationRule(string $type, array $options = []): string
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
switch ($type) {
|
||||
case 'int':
|
||||
case 'integer':
|
||||
case 'bigint':
|
||||
case 'smallint':
|
||||
case 'tinyint':
|
||||
$rules[] = 'integer';
|
||||
break;
|
||||
case 'float':
|
||||
case 'double':
|
||||
$rules[] = 'numeric';
|
||||
break;
|
||||
case 'bool':
|
||||
case 'boolean':
|
||||
$rules[] = 'boolean';
|
||||
break;
|
||||
case 'email':
|
||||
$rules[] = 'email';
|
||||
break;
|
||||
case 'url':
|
||||
$rules[] = 'url';
|
||||
break;
|
||||
case 'date':
|
||||
$rules[] = 'date';
|
||||
break;
|
||||
case 'datetime':
|
||||
$rules[] = 'datetime';
|
||||
break;
|
||||
default:
|
||||
$rules[] = 'string';
|
||||
}
|
||||
|
||||
if (isset($options['unique'])) {
|
||||
$rules[] = 'unique';
|
||||
}
|
||||
|
||||
return implode('|', $rules);
|
||||
}
|
||||
|
||||
protected function generateDocBlock(array $params = [], string $return = 'void'): string
|
||||
{
|
||||
$docBlock = "/**\n";
|
||||
|
||||
foreach ($params as $param => $type) {
|
||||
$docBlock .= " * @param {$type} \${$param}\n";
|
||||
}
|
||||
|
||||
if ($return !== 'void') {
|
||||
$docBlock .= " * @return {$return}\n";
|
||||
}
|
||||
|
||||
$docBlock .= " */";
|
||||
|
||||
return $docBlock;
|
||||
}
|
||||
|
||||
protected function formatPhpCode(string $code): string
|
||||
{
|
||||
// 简单的代码格式化
|
||||
$code = str_replace("\t", " ", $code);
|
||||
|
||||
// 确保换行符一致
|
||||
$code = str_replace("\r\n", "\n", $code);
|
||||
$code = str_replace("\r", "\n", $code);
|
||||
|
||||
return $code;
|
||||
}
|
||||
|
||||
protected function getNamespacePath(string $subNamespace = ''): string
|
||||
{
|
||||
$path = $this->basePath;
|
||||
|
||||
if (!empty($subNamespace)) {
|
||||
$path .= '/' . str_replace('\\', '/', $subNamespace);
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
protected function getFullNamespace(string $subNamespace = ''): string
|
||||
{
|
||||
$namespace = $this->namespace;
|
||||
|
||||
if (!empty($subNamespace)) {
|
||||
$namespace .= '\\' . $subNamespace;
|
||||
}
|
||||
|
||||
return $namespace;
|
||||
}
|
||||
|
||||
protected function showMessage(string $type, string $message): void
|
||||
{
|
||||
switch ($type) {
|
||||
case 'success':
|
||||
$this->output->writeln("<info>{$message}</info>");
|
||||
break;
|
||||
case 'error':
|
||||
$this->output->writeln("<error>{$message}</error>");
|
||||
break;
|
||||
case 'warning':
|
||||
$this->output->writeln("<comment>{$message}</comment>");
|
||||
break;
|
||||
default:
|
||||
$this->output->writeln($message);
|
||||
}
|
||||
}
|
||||
|
||||
protected function confirm(string $message, bool $default = true): bool
|
||||
{
|
||||
return $this->output->confirm($message, $default);
|
||||
}
|
||||
|
||||
protected function ask(string $question, $default = null): string
|
||||
{
|
||||
return $this->output->ask($question, $default);
|
||||
}
|
||||
|
||||
protected function choice(string $question, array $choices, $default = null): string
|
||||
{
|
||||
return $this->output->choice($question, $choices, $default);
|
||||
}
|
||||
|
||||
protected function multiChoice(string $question, array $choices, array $defaults = []): array
|
||||
{
|
||||
return $this->output->multiChoice($question, $choices, $defaults);
|
||||
}
|
||||
}
|
||||
436
fendx-framework/fendx-cli/src/Generator/ControllerGenerator.php
Normal file
436
fendx-framework/fendx-cli/src/Generator/ControllerGenerator.php
Normal file
@@ -0,0 +1,436 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Generator;
|
||||
|
||||
class ControllerGenerator extends CodeGenerator
|
||||
{
|
||||
protected function loadTemplates(): void
|
||||
{
|
||||
$this->templates = [
|
||||
'controller' => $this->getControllerTemplate(),
|
||||
'method' => $this->getMethodTemplate(),
|
||||
'api_controller' => $this->getApiControllerTemplate(),
|
||||
'resource_controller' => $this->getResourceControllerTemplate()
|
||||
];
|
||||
}
|
||||
|
||||
public function generate(string $name, array $options = []): bool
|
||||
{
|
||||
if (!$this->validateName($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$className = $this->getClassName($name) . 'Controller';
|
||||
$type = $options['type'] ?? 'basic';
|
||||
$resource = $options['resource'] ?? null;
|
||||
$api = $options['api'] ?? false;
|
||||
$methods = $options['methods'] ?? [];
|
||||
|
||||
$subNamespace = 'Controller';
|
||||
if ($api) {
|
||||
$subNamespace .= '\\Api';
|
||||
}
|
||||
|
||||
$path = $this->getNamespacePath($subNamespace);
|
||||
$namespace = $this->getFullNamespace($subNamespace);
|
||||
|
||||
if (!$this->createDirectory($path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$content = $this->generateController($className, $namespace, $type, $resource, $methods, $options);
|
||||
$filePath = $path . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($filePath, $content)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Controller '{$className}' generated successfully!");
|
||||
|
||||
// 生成路由提示
|
||||
$this->showRouteHint($className, $resource, $api);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateController(string $className, string $namespace, string $type, ?string $resource, array $methods, array $options): string
|
||||
{
|
||||
switch ($type) {
|
||||
case 'api':
|
||||
return $this->generateApiController($className, $namespace, $resource, $methods, $options);
|
||||
case 'resource':
|
||||
return $this->generateResourceController($className, $namespace, $resource, $options);
|
||||
default:
|
||||
return $this->generateBasicController($className, $namespace, $methods, $options);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateBasicController(string $className, string $namespace, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [];
|
||||
$classMethods = '';
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = ['index'];
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$classMethods .= $this->generateMethod($method, $options);
|
||||
|
||||
if (in_array($method, ['request', 'response'])) {
|
||||
$useStatements[] = 'use Fendx\\Web\\Request\\Request;';
|
||||
$useStatements[] = 'use Fendx\\Web\\Response\\Response;';
|
||||
}
|
||||
}
|
||||
|
||||
$useStatements = array_unique($useStatements);
|
||||
$useBlock = empty($useStatements) ? '' : implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('controller', [
|
||||
'namespace' => $namespace,
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'methods' => $classMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateApiController(string $className, string $namespace, ?string $resource, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Core\\Annotation\\Controller;',
|
||||
'use Fendx\\Web\\Annotation\\GetRoute;',
|
||||
'use Fendx\\Web\\Annotation\\PostRoute;',
|
||||
'use Fendx\\Web\\Annotation\\PutRoute;',
|
||||
'use Fendx\\Web\\Annotation\\DeleteRoute;',
|
||||
'use Fendx\\Web\\Request\\Request;',
|
||||
'use Fendx\\Web\\Response\\Response;'
|
||||
];
|
||||
|
||||
$classMethods = '';
|
||||
$resourceName = $resource ?: strtolower(str_replace('Controller', '', $className));
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = ['index', 'show', 'store', 'update', 'destroy'];
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$classMethods .= $this->generateApiMethod($method, $resourceName, $options);
|
||||
}
|
||||
|
||||
return $this->renderTemplate('api_controller', [
|
||||
'namespace' => $namespace,
|
||||
'use_block' => implode("\n", $useStatements) . "\n\n",
|
||||
'class_name' => $className,
|
||||
'resource_name' => $resourceName,
|
||||
'methods' => $classMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateResourceController(string $className, string $namespace, ?string $resource, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Core\\Annotation\\Controller;',
|
||||
'use Fendx\\Web\\Annotation\\GetRoute;',
|
||||
'use Fendx\\Web\\Annotation\\PostRoute;',
|
||||
'use Fendx\\Web\\Annotation\\PutRoute;',
|
||||
'use Fendx\\Web\\Annotation\\DeleteRoute;',
|
||||
'use Fendx\\Web\\Request\\Request;',
|
||||
'use Fendx\\Web\\Response\\Response;'
|
||||
];
|
||||
|
||||
$resourceName = $resource ?: strtolower(str_replace('Controller', '', $className));
|
||||
$modelClass = $this->getClassName($resourceName);
|
||||
$modelNamespace = $this->getFullNamespace('Model');
|
||||
|
||||
$classMethods = $this->generateResourceMethods($resourceName, $modelClass, $modelNamespace, $options);
|
||||
|
||||
return $this->renderTemplate('resource_controller', [
|
||||
'namespace' => $namespace,
|
||||
'use_block' => implode("\n", $useStatements) . "\n\n",
|
||||
'class_name' => $className,
|
||||
'resource_name' => $resourceName,
|
||||
'model_class' => $modelClass,
|
||||
'model_namespace' => $modelNamespace,
|
||||
'methods' => $classMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateMethod(string $method, array $options): string
|
||||
{
|
||||
$methodName = $method;
|
||||
$parameters = '';
|
||||
$body = '';
|
||||
$docBlock = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'index':
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$body = ' // TODO: Implement index method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array');
|
||||
$parameters = 'int $id';
|
||||
$body = ' // TODO: Implement show method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$body = ' // TODO: Implement create method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'store':
|
||||
$docBlock = $this->generateDocBlock(['request' => 'Request'], 'array');
|
||||
$parameters = 'Request $request';
|
||||
$useStatements[] = 'use Fendx\\Web\\Request\\Request;';
|
||||
$body = ' // TODO: Implement store method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'edit':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array');
|
||||
$parameters = 'int $id';
|
||||
$body = ' // TODO: Implement edit method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int', 'request' => 'Request'], 'array');
|
||||
$parameters = 'int $id, Request $request';
|
||||
$useStatements[] = 'use Fendx\\Web\\Request\\Request;';
|
||||
$body = ' // TODO: Implement update method' . "\n" . ' return [];';
|
||||
break;
|
||||
|
||||
case 'destroy':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$body = ' // TODO: Implement destroy method' . "\n" . ' return true;';
|
||||
break;
|
||||
|
||||
default:
|
||||
$docBlock = $this->generateDocBlock([], 'mixed');
|
||||
$body = ' // TODO: Implement ' . $method . ' method' . "\n" . ' return null;';
|
||||
}
|
||||
|
||||
return $this->renderTemplate('method', [
|
||||
'doc_block' => $docBlock,
|
||||
'method_name' => $methodName,
|
||||
'parameters' => $parameters,
|
||||
'body' => $body
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateApiMethod(string $method, string $resourceName, array $options): string
|
||||
{
|
||||
$methodName = $method;
|
||||
$route = '';
|
||||
$parameters = '';
|
||||
$body = '';
|
||||
$httpMethod = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'index':
|
||||
$route = "/{$resourceName}";
|
||||
$httpMethod = 'GetRoute';
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$body = ' $data = []; // TODO: Fetch data from database' . "\n" . ' return Response::success($data);';
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
$route = "/{$resourceName}/{id}";
|
||||
$httpMethod = 'GetRoute';
|
||||
$parameters = 'int $id';
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array');
|
||||
$body = ' $data = []; // TODO: Fetch item by id' . "\n" . ' return Response::success($data);';
|
||||
break;
|
||||
|
||||
case 'store':
|
||||
$route = "/{$resourceName}";
|
||||
$httpMethod = 'PostRoute';
|
||||
$parameters = 'Request $request';
|
||||
$docBlock = $this->generateDocBlock(['request' => 'Request'], 'array');
|
||||
$body = ' $data = $request->all();' . "\n" . ' // TODO: Store data to database' . "\n" . ' return Response::success($data, \'Resource created successfully\');';
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$route = "/{$resourceName}/{id}";
|
||||
$httpMethod = 'PutRoute';
|
||||
$parameters = 'int $id, Request $request';
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int', 'request' => 'Request'], 'array');
|
||||
$body = ' $data = $request->all();' . "\n" . ' // TODO: Update item by id' . "\n" . ' return Response::success($data, \'Resource updated successfully\');';
|
||||
break;
|
||||
|
||||
case 'destroy':
|
||||
$route = "/{$resourceName}/{id}";
|
||||
$httpMethod = 'DeleteRoute';
|
||||
$parameters = 'int $id';
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array');
|
||||
$body = ' // TODO: Delete item by id' . "\n" . ' return Response::success(null, \'Resource deleted successfully\');';
|
||||
break;
|
||||
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
#[{$httpMethod}('{$route}')]
|
||||
public function {$methodName}({$parameters}): array
|
||||
{
|
||||
{$body}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateResourceMethods(string $resourceName, string $modelClass, string $modelNamespace, array $options): string
|
||||
{
|
||||
$methods = '';
|
||||
|
||||
// Index method
|
||||
$methods .= <<<PHP
|
||||
#[GetRoute('/{$resourceName}')]
|
||||
public function index(): array
|
||||
{
|
||||
\$items = {$modelClass}::all();
|
||||
return Response::success(\$items);
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
// Show method
|
||||
$methods .= <<<PHP
|
||||
#[GetRoute('/{$resourceName}/{id}')]
|
||||
public function show(int \$id): array
|
||||
{
|
||||
\$item = {$modelClass}::find(\$id);
|
||||
if (!\$item) {
|
||||
return Response::error(404, '{$modelClass} not found');
|
||||
}
|
||||
return Response::success(\$item);
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
// Store method
|
||||
$methods .= <<<PHP
|
||||
#[PostRoute('/{$resourceName}')]
|
||||
public function store(Request \$request): array
|
||||
{
|
||||
\$data = \$request->all();
|
||||
\$item = {$modelClass}::create(\$data);
|
||||
return Response::success(\$item, '{$modelClass} created successfully');
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
// Update method
|
||||
$methods .= <<<PHP
|
||||
#[PutRoute('/{$resourceName}/{id}')]
|
||||
public function update(int \$id, Request \$request): array
|
||||
{
|
||||
\$item = {$modelClass}::find(\$id);
|
||||
if (!\$item) {
|
||||
return Response::error(404, '{$modelClass} not found');
|
||||
}
|
||||
|
||||
\$data = \$request->all();
|
||||
\$item->update(\$data);
|
||||
return Response::success(\$item, '{$modelClass} updated successfully');
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
// Destroy method
|
||||
$methods .= <<<PHP
|
||||
#[DeleteRoute('/{$resourceName}/{id}')]
|
||||
public function destroy(int \$id): array
|
||||
{
|
||||
\$item = {$modelClass}::find(\$id);
|
||||
if (!\$item) {
|
||||
return Response::error(404, '{$modelClass} not found');
|
||||
}
|
||||
|
||||
\$item->delete();
|
||||
return Response::success(null, '{$modelClass} deleted successfully');
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
private function showRouteHint(string $className, ?string $resource, bool $api): void
|
||||
{
|
||||
$controllerName = str_replace('Controller', '', $className);
|
||||
$this->output->writeln('');
|
||||
$this->output->writeln('<comment>Route registration hint:</comment>');
|
||||
|
||||
if ($api) {
|
||||
$this->output->writeln("Add this to your routes configuration:");
|
||||
$this->output->writeln("<info>\$router->mount('/api', function(\$router) {");
|
||||
$this->output->writeln(" \$router->registerController(new {$this->getFullNamespace('Controller\\Api')}\\{$className}());");
|
||||
$this->output->writeln("});</info>");
|
||||
} else {
|
||||
$this->output->writeln("Add this to your routes configuration:");
|
||||
$this->output->writeln("<info>\$router->registerController(new {$this->getFullNamespace('Controller')}\\{$className}());</info>");
|
||||
}
|
||||
}
|
||||
|
||||
private function getControllerTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}}
|
||||
{
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getMethodTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
{{doc_block}}
|
||||
public function {{method_name}}({{parameters}})
|
||||
{
|
||||
{{body}}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getApiControllerTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}#[Controller('/api')]
|
||||
class {{class_name}}
|
||||
{
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getResourceControllerTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}#[Controller('/api')]
|
||||
class {{class_name}}
|
||||
{
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
552
fendx-framework/fendx-cli/src/Generator/ModelGenerator.php
Normal file
552
fendx-framework/fendx-cli/src/Generator/ModelGenerator.php
Normal file
@@ -0,0 +1,552 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Generator;
|
||||
|
||||
class ModelGenerator extends CodeGenerator
|
||||
{
|
||||
protected function loadTemplates(): void
|
||||
{
|
||||
$this->templates = [
|
||||
'model' => $this->getModelTemplate(),
|
||||
'property' => $this->getPropertyTemplate(),
|
||||
'method' => $this->getMethodTemplate(),
|
||||
'migration' => $this->getMigrationTemplate(),
|
||||
'factory' => $this->getFactoryTemplate(),
|
||||
'seeder' => $this->getSeederTemplate()
|
||||
];
|
||||
}
|
||||
|
||||
public function generate(string $name, array $options = []): bool
|
||||
{
|
||||
if (!$this->validateName($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$className = $this->getClassName($name);
|
||||
$tableName = $options['table'] ?? $this->getTableName($name);
|
||||
$fields = $options['fields'] ?? [];
|
||||
$timestamps = $options['timestamps'] ?? true;
|
||||
$softDeletes = $options['soft_deletes'] ?? false;
|
||||
$relationships = $options['relationships'] ?? [];
|
||||
$generateMigration = $options['migration'] ?? true;
|
||||
$generateFactory = $options['factory'] ?? false;
|
||||
$generateSeeder = $options['seeder'] ?? false;
|
||||
|
||||
if (!empty($fields)) {
|
||||
if (is_string($fields)) {
|
||||
$fields = $this->parseFields($fields);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成模型
|
||||
$modelContent = $this->generateModel($className, $tableName, $fields, $timestamps, $softDeletes, $relationships);
|
||||
$modelPath = $this->getNamespacePath('Model');
|
||||
$modelFilePath = $modelPath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($modelFilePath, $modelContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 生成迁移文件
|
||||
if ($generateMigration) {
|
||||
$this->generateMigration($className, $tableName, $fields, $timestamps, $softDeletes);
|
||||
}
|
||||
|
||||
// 生成工厂文件
|
||||
if ($generateFactory) {
|
||||
$this->generateFactory($className, $fields);
|
||||
}
|
||||
|
||||
// 生成种子文件
|
||||
if ($generateSeeder) {
|
||||
$this->generateSeeder($className);
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Model '{$className}' generated successfully!");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateModel(string $className, string $tableName, array $fields, bool $timestamps, bool $softDeletes, array $relationships): string
|
||||
{
|
||||
$useStatements = [];
|
||||
$properties = '';
|
||||
$methods = '';
|
||||
|
||||
// 基础use语句
|
||||
$useStatements[] = 'use Fendx\\ORM\\Model;';
|
||||
|
||||
if ($softDeletes) {
|
||||
$useStatements[] = 'use Fendx\\ORM\\Traits\\SoftDeletes;';
|
||||
}
|
||||
|
||||
// 生成属性
|
||||
foreach ($fields as $field) {
|
||||
$properties .= $this->generateProperty($field);
|
||||
}
|
||||
|
||||
// 生成关系方法
|
||||
foreach ($relationships as $relationship) {
|
||||
$methods .= $this->generateRelationshipMethod($relationship);
|
||||
}
|
||||
|
||||
// 生成访问器和修改器
|
||||
foreach ($fields as $field) {
|
||||
$methods .= $this->generateAccessorMutator($field);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", array_unique($useStatements)) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('model', [
|
||||
'namespace' => $this->getFullNamespace('Model'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'table_name' => $tableName,
|
||||
'timestamps' => $timestamps ? 'true' : 'false',
|
||||
'soft_deletes' => $softDeletes ? 'use SoftDeletes;' : '',
|
||||
'properties' => $properties,
|
||||
'methods' => $methods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateProperty(array $field): string
|
||||
{
|
||||
$name = $field['name'];
|
||||
$type = $this->getPhpType($field['type']);
|
||||
$nullable = isset($field['options']['nullable']);
|
||||
|
||||
$docBlock = $this->generatePropertyDocBlock($field);
|
||||
|
||||
return $this->renderTemplate('property', [
|
||||
'doc_block' => $docBlock,
|
||||
'property_name' => $name,
|
||||
'property_type' => $type,
|
||||
'nullable' => $nullable ? '?' : ''
|
||||
]);
|
||||
}
|
||||
|
||||
private function generatePropertyDocBlock(array $field): string
|
||||
{
|
||||
$type = $this->getPhpType($field['type']);
|
||||
$comment = '';
|
||||
|
||||
if (isset($field['options']['unique'])) {
|
||||
$comment .= ' (unique)';
|
||||
}
|
||||
if (isset($field['options']['default'])) {
|
||||
$comment .= ' (default: ' . $field['options']['default'] . ')';
|
||||
}
|
||||
if (isset($field['options']['nullable'])) {
|
||||
$comment .= ' (nullable)';
|
||||
}
|
||||
|
||||
return "/**\n * @var {$type}{$comment}\n */";
|
||||
}
|
||||
|
||||
private function generateRelationshipMethod(array $relationship): string
|
||||
{
|
||||
$type = $relationship['type'];
|
||||
$relatedModel = $relationship['model'];
|
||||
$foreignKey = $relationship['foreign_key'] ?? null;
|
||||
$localKey = $relationship['local_key'] ?? null;
|
||||
|
||||
switch ($type) {
|
||||
case 'hasOne':
|
||||
return $this->generateHasOneMethod($relatedModel, $foreignKey, $localKey);
|
||||
case 'hasMany':
|
||||
return $this->generateHasManyMethod($relatedModel, $foreignKey, $localKey);
|
||||
case 'belongsTo':
|
||||
return $this->generateBelongsToMethod($relatedModel, $foreignKey, $localKey);
|
||||
case 'belongsToMany':
|
||||
return $this->generateBelongsToManyMethod($relatedModel, $foreignKey, $localKey);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private function generateHasOneMethod(string $relatedModel, ?string $foreignKey, ?string $localKey): string
|
||||
{
|
||||
$methodName = $this->getVariableName($relatedModel);
|
||||
$params = '';
|
||||
|
||||
if ($foreignKey) {
|
||||
$params .= ", '{$foreignKey}'";
|
||||
}
|
||||
if ($localKey) {
|
||||
$params .= ", '{$localKey}'";
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
/**
|
||||
* Get the associated {$relatedModel}.
|
||||
*/
|
||||
public function {$methodName}(): HasOne
|
||||
{
|
||||
return \$this->hasOne({$relatedModel}::class{$params});
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateHasManyMethod(string $relatedModel, ?string $foreignKey, ?string $localKey): string
|
||||
{
|
||||
$methodName = $this->getPluralName($this->getVariableName($relatedModel));
|
||||
$params = '';
|
||||
|
||||
if ($foreignKey) {
|
||||
$params .= ", '{$foreignKey}'";
|
||||
}
|
||||
if ($localKey) {
|
||||
$params .= ", '{$localKey}'";
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
/**
|
||||
* Get the associated {$relatedModel} records.
|
||||
*/
|
||||
public function {$methodName}(): HasMany
|
||||
{
|
||||
return \$this->hasMany({$relatedModel}::class{$params});
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateBelongsToMethod(string $relatedModel, ?string $foreignKey, ?string $localKey): string
|
||||
{
|
||||
$methodName = $this->getVariableName($relatedModel);
|
||||
$params = '';
|
||||
|
||||
if ($foreignKey) {
|
||||
$params .= ", '{$foreignKey}'";
|
||||
}
|
||||
if ($localKey) {
|
||||
$params .= ", '{$localKey}'";
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
/**
|
||||
* Get the associated {$relatedModel}.
|
||||
*/
|
||||
public function {$methodName}(): BelongsTo
|
||||
{
|
||||
return \$this->belongsTo({$relatedModel}::class{$params});
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateBelongsToManyMethod(string $relatedModel, ?string $foreignKey, ?string $localKey): string
|
||||
{
|
||||
$methodName = $this->getPluralName($this->getVariableName($relatedModel));
|
||||
$params = '';
|
||||
|
||||
if ($foreignKey) {
|
||||
$params .= ", '{$foreignKey}'";
|
||||
}
|
||||
if ($localKey) {
|
||||
$params .= ", '{$localKey}'";
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
/**
|
||||
* Get the associated {$relatedModel} records.
|
||||
*/
|
||||
public function {$methodName}(): BelongsToMany
|
||||
{
|
||||
return \$this->belongsToMany({$relatedModel}::class{$params});
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateAccessorMutator(array $field): string
|
||||
{
|
||||
$name = $field['name'];
|
||||
$type = $this->getPhpType($field['type']);
|
||||
$methods = '';
|
||||
|
||||
// 生成访问器
|
||||
$accessorName = 'get' . $this->getClassName($name) . 'Attribute';
|
||||
$methods .= <<<PHP
|
||||
/**
|
||||
* Get the {$name} attribute.
|
||||
*/
|
||||
public function {$accessorName}(): {$type}
|
||||
{
|
||||
return \$this->attributes['{$name}'];
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
// 生成修改器
|
||||
$mutatorName = 'set' . $this->getClassName($name) . 'Attribute';
|
||||
$methods .= <<<PHP
|
||||
/**
|
||||
* Set the {$name} attribute.
|
||||
*/
|
||||
public function {$mutatorName}({$type} \$value): void
|
||||
{
|
||||
\$this->attributes['{$name}'] = \$value;
|
||||
}
|
||||
|
||||
PHP;
|
||||
|
||||
return $methods;
|
||||
}
|
||||
|
||||
private function generateMigration(string $className, string $tableName, array $fields, bool $timestamps, bool $softDeletes): void
|
||||
{
|
||||
$migrationName = 'create_' . $tableName . '_table';
|
||||
$migrationClassName = $this->getClassName($migrationName);
|
||||
|
||||
$content = $this->generateMigrationContent($migrationClassName, $tableName, $fields, $timestamps, $softDeletes);
|
||||
|
||||
$migrationPath = $this->getNamespacePath('../database/migrations');
|
||||
$timestamp = date('Y_m_d_His');
|
||||
$filename = $timestamp . '_' . $migrationName . '.php';
|
||||
$filePath = $migrationPath . '/' . $filename;
|
||||
|
||||
$this->createFile($filePath, $content);
|
||||
}
|
||||
|
||||
private function generateMigrationContent(string $className, string $tableName, array $fields, bool $timestamps, bool $softDeletes): string
|
||||
{
|
||||
$upMethods = '';
|
||||
$downMethods = '';
|
||||
|
||||
// 生成字段定义
|
||||
foreach ($fields as $field) {
|
||||
$upMethods .= $this->generateMigrationField($field);
|
||||
}
|
||||
|
||||
// 生成时间戳
|
||||
if ($timestamps) {
|
||||
$upMethods .= " \$table->timestamps();\n";
|
||||
$downMethods .= " \$table->dropTimestamps();\n";
|
||||
}
|
||||
|
||||
// 生成软删除
|
||||
if ($softDeletes) {
|
||||
$upMethods .= " \$table->softDeletes();\n";
|
||||
$downMethods .= " \$table->dropSoftDeletes();\n";
|
||||
}
|
||||
|
||||
return $this->renderTemplate('migration', [
|
||||
'class_name' => $className,
|
||||
'table_name' => $tableName,
|
||||
'up_methods' => $upMethods,
|
||||
'down_methods' => $downMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateMigrationField(array $field): string
|
||||
{
|
||||
$name = $field['name'];
|
||||
$type = $field['type'];
|
||||
$options = $field['options'] ?? [];
|
||||
|
||||
$method = "\$table->{$type}('{$name}')";
|
||||
|
||||
if (isset($options['nullable'])) {
|
||||
$method .= '->nullable()';
|
||||
}
|
||||
|
||||
if (isset($options['default'])) {
|
||||
$default = $options['default'];
|
||||
if (is_string($default)) {
|
||||
$default = "'{$default}'";
|
||||
}
|
||||
$method .= "->default({$default})";
|
||||
}
|
||||
|
||||
if (isset($options['unique'])) {
|
||||
$method .= '->unique()';
|
||||
}
|
||||
|
||||
return " {$method};\n";
|
||||
}
|
||||
|
||||
private function generateFactory(string $className, array $fields): void
|
||||
{
|
||||
$factoryClassName = $className . 'Factory';
|
||||
$definition = $this->generateFactoryDefinition($className, $fields);
|
||||
|
||||
$content = $this->renderTemplate('factory', [
|
||||
'namespace' => $this->getFullNamespace('Database\\Factories'),
|
||||
'class_name' => $factoryClassName,
|
||||
'model_class' => $className,
|
||||
'definition' => $definition
|
||||
]);
|
||||
|
||||
$factoryPath = $this->getNamespacePath('../database/factories');
|
||||
$filePath = $factoryPath . '/' . $factoryClassName . '.php';
|
||||
|
||||
$this->createFile($filePath, $content);
|
||||
}
|
||||
|
||||
private function generateFactoryDefinition(string $className, array $fields): string
|
||||
{
|
||||
$definition = '';
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$name = $field['name'];
|
||||
$type = $field['type'];
|
||||
$faker = $this->getFakerMethod($type, $field['options'] ?? []);
|
||||
|
||||
$definition .= " '{$name}' => {$faker},\n";
|
||||
}
|
||||
|
||||
return $definition;
|
||||
}
|
||||
|
||||
private function getFakerMethod(string $type, array $options): string
|
||||
{
|
||||
$fakerMap = [
|
||||
'string' => '$this->faker->sentence()',
|
||||
'varchar' => '$this->faker->word()',
|
||||
'text' => '$this->faker->paragraph()',
|
||||
'int' => '$this->faker->numberBetween(1, 1000)',
|
||||
'integer' => '$this->faker->numberBetween(1, 1000)',
|
||||
'bigint' => '$this->faker->numberBetween(1, 1000000)',
|
||||
'float' => '$this->faker->randomFloat(2, 0, 1000)',
|
||||
'double' => '$this->faker->randomFloat(2, 0, 1000)',
|
||||
'bool' => '$this->faker->boolean()',
|
||||
'boolean' => '$this->faker->boolean()',
|
||||
'date' => '$this->faker->date()',
|
||||
'datetime' => '$this->faker->datetime()',
|
||||
'timestamp' => '$this->faker->datetime()',
|
||||
'email' => '$this->faker->email()',
|
||||
'url' => '$this->faker->url()'
|
||||
];
|
||||
|
||||
return $fakerMap[$type] ?? '$this->faker->word()';
|
||||
}
|
||||
|
||||
private function generateSeeder(string $className): void
|
||||
{
|
||||
$seederClassName = $className . 'Seeder';
|
||||
$modelVariable = $this->getVariableName($className);
|
||||
|
||||
$content = $this->renderTemplate('seeder', [
|
||||
'namespace' => $this->getFullNamespace('Database\\Seeders'),
|
||||
'class_name' => $seederClassName,
|
||||
'model_class' => $className,
|
||||
'model_variable' => $modelVariable,
|
||||
'model_namespace' => $this->getFullNamespace('Model')
|
||||
]);
|
||||
|
||||
$seederPath = $this->getNamespacePath('../database/seeders');
|
||||
$filePath = $seederPath . '/' . $seederClassName . '.php';
|
||||
|
||||
$this->createFile($filePath, $content);
|
||||
}
|
||||
|
||||
private function getModelTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends Model
|
||||
{
|
||||
protected string \$table = '{{table_name}}';
|
||||
protected bool \$timestamps = {{timestamps}};
|
||||
{{soft_deletes}}
|
||||
|
||||
{{properties}}
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getPropertyTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
{{doc_block}}
|
||||
private {{nullable}}{{property_type}} \${{property_name}};
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getMethodTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
{{method_content}}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getMigrationTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Fendx\Database\Migration;
|
||||
use Fendx\Database\Schema\Blueprint;
|
||||
|
||||
class {{class_name}} extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
\$this->schema->create('{{table_name}}', function (Blueprint \$table) {
|
||||
{{up_methods}});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
\$this->schema->dropIfExists('{{table_name}}');
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getFactoryTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
use Fendx\Database\Factories\Factory;
|
||||
use {{model_namespace}}\{{model_class}};
|
||||
|
||||
class {{class_name}} extends Factory
|
||||
{
|
||||
protected string \$model = {{model_class}}::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
{{definition}};
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getSeederTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
use Fendx\Database\Seeder;
|
||||
use {{model_namespace}}\{{model_class}};
|
||||
|
||||
class {{class_name}} extends Seeder
|
||||
{
|
||||
public function run(): void
|
||||
{
|
||||
{{model_class}}::factory()->count(10)->create();
|
||||
}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
540
fendx-framework/fendx-cli/src/Generator/ServiceGenerator.php
Normal file
540
fendx-framework/fendx-cli/src/Generator/ServiceGenerator.php
Normal file
@@ -0,0 +1,540 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Generator;
|
||||
|
||||
class ServiceGenerator extends CodeGenerator
|
||||
{
|
||||
protected function loadTemplates(): void
|
||||
{
|
||||
$this->templates = [
|
||||
'service' => $this->getServiceTemplate(),
|
||||
'interface' => $this->getInterfaceTemplate(),
|
||||
'method' => $this->getMethodTemplate(),
|
||||
'repository' => $this->getRepositoryTemplate(),
|
||||
'dto' => $this->getDtoTemplate()
|
||||
];
|
||||
}
|
||||
|
||||
public function generate(string $name, array $options = []): bool
|
||||
{
|
||||
if (!$this->validateName($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$className = $this->getClassName($name) . 'Service';
|
||||
$interfaceName = $this->getClassName($name) . 'ServiceInterface';
|
||||
$repositoryName = $this->getClassName($name) . 'Repository';
|
||||
$type = $options['type'] ?? 'basic';
|
||||
$model = $options['model'] ?? null;
|
||||
$methods = $options['methods'] ?? [];
|
||||
$generateInterface = $options['interface'] ?? true;
|
||||
$generateRepository = $options['repository'] ?? false;
|
||||
$generateDto = $options['dto'] ?? false;
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = ['getAll', 'getById', 'create', 'update', 'delete'];
|
||||
}
|
||||
|
||||
// 生成服务接口
|
||||
if ($generateInterface) {
|
||||
$this->generateInterface($interfaceName, $methods, $model);
|
||||
}
|
||||
|
||||
// 生成服务类
|
||||
$serviceContent = $this->generateService($className, $interfaceName, $repositoryName, $type, $methods, $model, $options);
|
||||
$servicePath = $this->getNamespacePath('Service');
|
||||
$serviceFilePath = $servicePath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($serviceFilePath, $serviceContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 生成仓储类
|
||||
if ($generateRepository && $model) {
|
||||
$this->generateRepository($repositoryName, $model, $methods);
|
||||
}
|
||||
|
||||
// 生成DTO类
|
||||
if ($generateDto) {
|
||||
$this->generateDto($name, $methods);
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Service '{$className}' generated successfully!");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateInterface(string $interfaceName, array $methods, ?string $model): void
|
||||
{
|
||||
$interfaceContent = $this->generateInterfaceContent($interfaceName, $methods, $model);
|
||||
$interfacePath = $this->getNamespacePath('Service\\Contract');
|
||||
$interfaceFilePath = $interfacePath . '/' . $interfaceName . '.php';
|
||||
|
||||
$this->createFile($interfaceFilePath, $interfaceContent);
|
||||
}
|
||||
|
||||
private function generateInterfaceContent(string $interfaceName, array $methods, ?string $model): string
|
||||
{
|
||||
$methodDefinitions = '';
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$methodDefinitions .= $this->generateInterfaceMethod($method, $model);
|
||||
}
|
||||
|
||||
return $this->renderTemplate('interface', [
|
||||
'namespace' => $this->getFullNamespace('Service\\Contract'),
|
||||
'interface_name' => $interfaceName,
|
||||
'methods' => $methodDefinitions
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateInterfaceMethod(string $method, ?string $model): string
|
||||
{
|
||||
$methodName = $method;
|
||||
$parameters = '';
|
||||
$returnType = 'mixed';
|
||||
$docBlock = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'getAll':
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$returnType = 'array';
|
||||
break;
|
||||
|
||||
case 'getById':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array|null');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'array|null';
|
||||
break;
|
||||
|
||||
case 'getBy':
|
||||
$docBlock = $this->generateDocBlock(['criteria' => 'array'], 'array');
|
||||
$parameters = 'array $criteria';
|
||||
$returnType = 'array';
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$docBlock = $this->generateDocBlock(['data' => 'array'], 'array');
|
||||
$parameters = 'array $data';
|
||||
$returnType = 'array';
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int', 'data' => 'array'], 'array|null');
|
||||
$parameters = 'int $id, array $data';
|
||||
$returnType = 'array|null';
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
break;
|
||||
|
||||
case 'exists':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
$docBlock = $this->generateDocBlock([], 'int');
|
||||
$returnType = 'int';
|
||||
break;
|
||||
|
||||
default:
|
||||
$docBlock = $this->generateDocBlock([], 'mixed');
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
{$docBlock}
|
||||
public function {$methodName}({$parameters}): {$returnType};
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateService(string $className, string $interfaceName, string $repositoryName, string $type, array $methods, ?string $model, array $options): string
|
||||
{
|
||||
$useStatements = [];
|
||||
$properties = '';
|
||||
$constructor = '';
|
||||
$methodImplementations = '';
|
||||
|
||||
// 基础use语句
|
||||
$useStatements[] = 'use Fendx\\CLI\\Generator\\Service\\Contract\\' . $interfaceName . ';';
|
||||
|
||||
if ($model) {
|
||||
$modelClass = $this->getClassName($model);
|
||||
$modelNamespace = $this->getFullNamespace('Model');
|
||||
$useStatements[] = 'use ' . $modelNamespace . '\\' . $modelClass . ';';
|
||||
}
|
||||
|
||||
if ($type === 'repository' && $model) {
|
||||
$repositoryClass = $repositoryName;
|
||||
$repositoryNamespace = $this->getFullNamespace('Repository');
|
||||
$useStatements[] = 'use ' . $repositoryNamespace . '\\' . $repositoryClass . ';';
|
||||
|
||||
$properties = " private {$repositoryClass} \$repository;\n\n";
|
||||
$constructor = <<<PHP
|
||||
public function __construct({$repositoryClass} \$repository)
|
||||
{
|
||||
\$this->repository = \$repository;
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
// 生成方法实现
|
||||
foreach ($methods as $method) {
|
||||
$methodImplementations .= $this->generateServiceMethod($method, $type, $model, $options);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", array_unique($useStatements)) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('service', [
|
||||
'namespace' => $this->getFullNamespace('Service'),
|
||||
'use_block' => $useBlock,
|
||||
'interface_name' => $interfaceName,
|
||||
'class_name' => $className,
|
||||
'properties' => $properties,
|
||||
'constructor' => $constructor,
|
||||
'methods' => $methodImplementations
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateServiceMethod(string $method, string $type, ?string $model, array $options): string
|
||||
{
|
||||
$methodName = $method;
|
||||
$parameters = '';
|
||||
$returnType = 'mixed';
|
||||
$body = '';
|
||||
$docBlock = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'getAll':
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$returnType = 'array';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->findAll();';
|
||||
} else {
|
||||
$body = ' // TODO: Implement getAll method' . "\n" . ' return [];';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getById':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array|null');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'array|null';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->findById($id);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement getById method' . "\n" . ' return null;';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'getBy':
|
||||
$docBlock = $this->generateDocBlock(['criteria' => 'array'], 'array');
|
||||
$parameters = 'array $criteria';
|
||||
$returnType = 'array';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->findBy($criteria);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement getBy method' . "\n" . ' return [];';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$docBlock = $this->generateDocBlock(['data' => 'array'], 'array');
|
||||
$parameters = 'array $data';
|
||||
$returnType = 'array';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->create($data);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement create method' . "\n" . ' return [];';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int', 'data' => 'array'], 'array|null');
|
||||
$parameters = 'int $id, array $data';
|
||||
$returnType = 'array|null';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->update($id, $data);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement update method' . "\n" . ' return null;';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->delete($id);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement delete method' . "\n" . ' return false;';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'exists':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->exists($id);';
|
||||
} else {
|
||||
$body = ' // TODO: Implement exists method' . "\n" . ' return false;';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
$docBlock = $this->generateDocBlock([], 'int');
|
||||
$returnType = 'int';
|
||||
if ($type === 'repository') {
|
||||
$body = ' return $this->repository->count();';
|
||||
} else {
|
||||
$body = ' // TODO: Implement count method' . "\n" . ' return 0;';
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$docBlock = $this->generateDocBlock([], 'mixed');
|
||||
$body = ' // TODO: Implement ' . $method . ' method' . "\n" . ' return null;';
|
||||
}
|
||||
|
||||
return $this->renderTemplate('method', [
|
||||
'doc_block' => $docBlock,
|
||||
'method_name' => $methodName,
|
||||
'parameters' => $parameters,
|
||||
'return_type' => $returnType,
|
||||
'body' => $body
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateRepository(string $repositoryName, string $model, array $methods): void
|
||||
{
|
||||
$modelClass = $this->getClassName($model);
|
||||
$modelNamespace = $this->getFullNamespace('Model');
|
||||
|
||||
$repositoryContent = $this->generateRepositoryContent($repositoryName, $modelClass, $modelNamespace, $methods);
|
||||
$repositoryPath = $this->getNamespacePath('Repository');
|
||||
$repositoryFilePath = $repositoryPath . '/' . $repositoryName . '.php';
|
||||
|
||||
$this->createFile($repositoryFilePath, $repositoryContent);
|
||||
}
|
||||
|
||||
private function generateRepositoryContent(string $repositoryName, string $modelClass, string $modelNamespace, array $methods): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\ORM\\Repository;',
|
||||
'use ' . $modelNamespace . '\\' . $modelClass . ';'
|
||||
];
|
||||
|
||||
$methodImplementations = '';
|
||||
foreach ($methods as $method) {
|
||||
$methodImplementations .= $this->generateRepositoryMethod($method, $modelClass);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('repository', [
|
||||
'namespace' => $this->getFullNamespace('Repository'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $repositoryName,
|
||||
'model_class' => $modelClass,
|
||||
'methods' => $methodImplementations
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateRepositoryMethod(string $method, string $modelClass): string
|
||||
{
|
||||
$methodName = $method;
|
||||
$parameters = '';
|
||||
$returnType = 'mixed';
|
||||
$body = '';
|
||||
$docBlock = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'findAll':
|
||||
$docBlock = $this->generateDocBlock([], 'array');
|
||||
$returnType = 'array';
|
||||
$body = ' return ' . $modelClass . '::all();';
|
||||
break;
|
||||
|
||||
case 'findById':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'array|null');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'array|null';
|
||||
$body = ' return ' . $modelClass . '::find($id);';
|
||||
break;
|
||||
|
||||
case 'findBy':
|
||||
$docBlock = $this->generateDocBlock(['criteria' => 'array'], 'array');
|
||||
$parameters = 'array $criteria';
|
||||
$returnType = 'array';
|
||||
$body = ' return ' . $modelClass . '::where($criteria)->get();';
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$docBlock = $this->generateDocBlock(['data' => 'array'], 'array');
|
||||
$parameters = 'array $data';
|
||||
$returnType = 'array';
|
||||
$body = ' return ' . $modelClass . '::create($data);';
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int', 'data' => 'array'], 'array|null');
|
||||
$parameters = 'int $id, array $data';
|
||||
$returnType = 'array|null';
|
||||
$body = ' $item = ' . $modelClass . '::find($id);' . "\n" . ' if ($item) {' . "\n" . ' $item->update($data);' . "\n" . ' return $item;' . "\n" . ' }' . "\n" . ' return null;';
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
$body = ' $item = ' . $modelClass . '::find($id);' . "\n" . ' if ($item) {' . "\n" . ' return $item->delete();' . "\n" . ' }' . "\n" . ' return false;';
|
||||
break;
|
||||
|
||||
case 'exists':
|
||||
$docBlock = $this->generateDocBlock(['id' => 'int'], 'bool');
|
||||
$parameters = 'int $id';
|
||||
$returnType = 'bool';
|
||||
$body = ' return ' . $modelClass . '::where(\'id\', $id)->exists();';
|
||||
break;
|
||||
|
||||
case 'count':
|
||||
$docBlock = $this->generateDocBlock([], 'int');
|
||||
$returnType = 'int';
|
||||
$body = ' return ' . $modelClass . '::count();';
|
||||
break;
|
||||
|
||||
default:
|
||||
$docBlock = $this->generateDocBlock([], 'mixed');
|
||||
$body = ' // TODO: Implement ' . $method . ' method' . "\n" . ' return null;';
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
{$docBlock}
|
||||
public function {$methodName}({$parameters}): {$returnType}
|
||||
{
|
||||
{$body}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateDto(string $name, array $methods): void
|
||||
{
|
||||
$dtoName = $this->getClassName($name) . 'Dto';
|
||||
$properties = $this->generateDtoProperties($methods);
|
||||
|
||||
$content = $this->renderTemplate('dto', [
|
||||
'namespace' => $this->getFullNamespace('DTO'),
|
||||
'class_name' => $dtoName,
|
||||
'properties' => $properties
|
||||
]);
|
||||
|
||||
$dtoPath = $this->getNamespacePath('DTO');
|
||||
$dtoFilePath = $dtoPath . '/' . $dtoName . '.php';
|
||||
|
||||
$this->createFile($dtoFilePath, $content);
|
||||
}
|
||||
|
||||
private function generateDtoProperties(array $methods): string
|
||||
{
|
||||
$properties = '';
|
||||
|
||||
foreach ($methods as $method) {
|
||||
switch ($method) {
|
||||
case 'create':
|
||||
$properties .= " public array \$data;\n\n";
|
||||
break;
|
||||
case 'update':
|
||||
$properties .= " public int \$id;\n";
|
||||
$properties .= " public array \$data;\n\n";
|
||||
break;
|
||||
case 'getBy':
|
||||
$properties .= " public array \$criteria;\n\n";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $properties;
|
||||
}
|
||||
|
||||
private function getServiceTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} implements {{interface_name}}
|
||||
{
|
||||
{{properties}}{{constructor}}{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getInterfaceTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
interface {{interface_name}}
|
||||
{
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getMethodTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
{{doc_block}}
|
||||
public function {{method_name}}({{parameters}}): {{return_type}}
|
||||
{
|
||||
{{body}}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getRepositoryTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends Repository
|
||||
{
|
||||
protected string \$model = {{model_class}}::class;
|
||||
|
||||
{{methods}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getDtoTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
class {{class_name}}
|
||||
{
|
||||
{{properties}}
|
||||
}
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
769
fendx-framework/fendx-cli/src/Generator/TestGenerator.php
Normal file
769
fendx-framework/fendx-cli/src/Generator/TestGenerator.php
Normal file
@@ -0,0 +1,769 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Generator;
|
||||
|
||||
class TestGenerator extends CodeGenerator
|
||||
{
|
||||
protected function loadTemplates(): void
|
||||
{
|
||||
$this->templates = [
|
||||
'test' => $this->getTestTemplate(),
|
||||
'unit_test' => $this->getUnitTestTemplate(),
|
||||
'feature_test' => $this->getFeatureTestTemplate(),
|
||||
'api_test' => $this->getApiTestTemplate(),
|
||||
'test_method' => $this->getTestMethodTemplate(),
|
||||
'assertion' => $this->getAssertionTemplate()
|
||||
];
|
||||
}
|
||||
|
||||
public function generate(string $name, array $options = []): bool
|
||||
{
|
||||
if (!$this->validateName($name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$type = $options['type'] ?? 'unit';
|
||||
$target = $options['target'] ?? null;
|
||||
$methods = $options['methods'] ?? [];
|
||||
$api = $options['api'] ?? false;
|
||||
$feature = $options['feature'] ?? false;
|
||||
|
||||
$className = $this->getClassName($name) . 'Test';
|
||||
|
||||
if ($type === 'unit' && $target) {
|
||||
return $this->generateUnitTest($className, $target, $methods, $options);
|
||||
} elseif ($type === 'feature' && $target) {
|
||||
return $this->generateFeatureTest($className, $target, $methods, $options);
|
||||
} elseif ($type === 'api' && $target) {
|
||||
return $this->generateApiTest($className, $target, $methods, $options);
|
||||
} else {
|
||||
return $this->generateBasicTest($className, $methods, $options);
|
||||
}
|
||||
}
|
||||
|
||||
private function generateBasicTest(string $className, array $methods, array $options): bool
|
||||
{
|
||||
$testContent = $this->generateBasicTestContent($className, $methods, $options);
|
||||
$testPath = $this->getNamespacePath('../tests/Unit');
|
||||
$testFilePath = $testPath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($testFilePath, $testContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Test '{$className}' generated successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateUnitTest(string $className, string $target, array $methods, array $options): bool
|
||||
{
|
||||
$targetClass = $this->getClassName($target);
|
||||
$targetNamespace = $this->getTargetNamespace($target);
|
||||
|
||||
$testContent = $this->generateUnitTestContent($className, $targetClass, $targetNamespace, $methods, $options);
|
||||
$testPath = $this->getNamespacePath('../tests/Unit');
|
||||
$testFilePath = $testPath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($testFilePath, $testContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Unit test '{$className}' generated successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateFeatureTest(string $className, string $target, array $methods, array $options): bool
|
||||
{
|
||||
$targetClass = $this->getClassName($target);
|
||||
$targetNamespace = $this->getTargetNamespace($target);
|
||||
|
||||
$testContent = $this->generateFeatureTestContent($className, $targetClass, $targetNamespace, $methods, $options);
|
||||
$testPath = $this->getNamespacePath('../tests/Feature');
|
||||
$testFilePath = $testPath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($testFilePath, $testContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->showMessage('success', "Feature test '{$className}' generated successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateApiTest(string $className, string $target, array $methods, array $options): bool
|
||||
{
|
||||
$targetClass = $this->getClassName($target);
|
||||
$targetNamespace = $this->getTargetNamespace($target);
|
||||
|
||||
$testContent = $this->generateApiTestContent($className, $targetClass, $targetNamespace, $methods, $options);
|
||||
$testPath = $this->getNamespacePath('../tests/Api');
|
||||
$testFilePath = $testPath . '/' . $className . '.php';
|
||||
|
||||
if (!$this->createFile($testFilePath, $testContent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->showMessage('success', "API test '{$className}' generated successfully!");
|
||||
return true;
|
||||
}
|
||||
|
||||
private function generateBasicTestContent(string $className, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Test\\TestCase;'
|
||||
];
|
||||
|
||||
$setupMethod = $this->generateSetupMethod();
|
||||
$testMethods = '';
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = ['test_example'];
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$testMethods .= $this->generateTestMethod($method, 'basic', null, $options);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('test', [
|
||||
'namespace' => $this->getFullNamespace('Tests\\Unit'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'setup_method' => $setupMethod,
|
||||
'test_methods' => $testMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateUnitTestContent(string $className, string $targetClass, string $targetNamespace, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Test\\TestCase;',
|
||||
'use ' . $targetNamespace . '\\' . $targetClass . ';'
|
||||
];
|
||||
|
||||
$properties = " private {$targetClass} \${$this->getVariableName($targetClass)};\n\n";
|
||||
$setupMethod = $this->generateSetupMethod($targetClass);
|
||||
$testMethods = '';
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = $this->getDefaultMethodsForClass($targetClass);
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$testMethods .= $this->generateTestMethod($method, 'unit', $targetClass, $options);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('unit_test', [
|
||||
'namespace' => $this->getFullNamespace('Tests\\Unit'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'target_class' => $targetClass,
|
||||
'target_variable' => $this->getVariableName($targetClass),
|
||||
'properties' => $properties,
|
||||
'setup_method' => $setupMethod,
|
||||
'test_methods' => $testMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateFeatureTestContent(string $className, string $targetClass, string $targetNamespace, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Test\\TestCase;',
|
||||
'use Fendx\\Test\\Concerns\\MakesHttpRequests;'
|
||||
];
|
||||
|
||||
$setupMethod = $this->generateFeatureSetupMethod();
|
||||
$testMethods = '';
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = $this->getDefaultMethodsForFeature($targetClass);
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$testMethods .= $this->generateTestMethod($method, 'feature', $targetClass, $options);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('feature_test', [
|
||||
'namespace' => $this->getFullNamespace('Tests\\Feature'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'target_class' => $targetClass,
|
||||
'setup_method' => $setupMethod,
|
||||
'test_methods' => $testMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateApiTestContent(string $className, string $targetClass, string $targetNamespace, array $methods, array $options): string
|
||||
{
|
||||
$useStatements = [
|
||||
'use Fendx\\Test\\TestCase;',
|
||||
'use Fendx\\Test\\Concerns\\MakesHttpRequests;'
|
||||
];
|
||||
|
||||
$setupMethod = $this->generateApiSetupMethod();
|
||||
$testMethods = '';
|
||||
|
||||
if (empty($methods)) {
|
||||
$methods = $this->getDefaultMethodsForApi($targetClass);
|
||||
}
|
||||
|
||||
foreach ($methods as $method) {
|
||||
$testMethods .= $this->generateTestMethod($method, 'api', $targetClass, $options);
|
||||
}
|
||||
|
||||
$useBlock = implode("\n", $useStatements) . "\n\n";
|
||||
|
||||
return $this->renderTemplate('api_test', [
|
||||
'namespace' => $this->getFullNamespace('Tests\\Api'),
|
||||
'use_block' => $useBlock,
|
||||
'class_name' => $className,
|
||||
'target_class' => $targetClass,
|
||||
'setup_method' => $setupMethod,
|
||||
'test_methods' => $testMethods
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateTestMethod(string $method, string $type, ?string $targetClass, array $options): string
|
||||
{
|
||||
$methodName = $this->getTestMethodName($method);
|
||||
$docBlock = $this->generateTestDocBlock($method, $type, $targetClass);
|
||||
$body = '';
|
||||
|
||||
switch ($type) {
|
||||
case 'unit':
|
||||
$body = $this->generateUnitTestBody($method, $targetClass, $options);
|
||||
break;
|
||||
case 'feature':
|
||||
$body = $this->generateFeatureTestBody($method, $targetClass, $options);
|
||||
break;
|
||||
case 'api':
|
||||
$body = $this->generateApiTestBody($method, $targetClass, $options);
|
||||
break;
|
||||
default:
|
||||
$body = $this->generateBasicTestBody($method, $options);
|
||||
}
|
||||
|
||||
return $this->renderTemplate('test_method', [
|
||||
'doc_block' => $docBlock,
|
||||
'method_name' => $methodName,
|
||||
'body' => $body
|
||||
]);
|
||||
}
|
||||
|
||||
private function generateSetupMethod(?string $targetClass = null): string
|
||||
{
|
||||
$body = '';
|
||||
|
||||
if ($targetClass) {
|
||||
$variable = $this->getVariableName($targetClass);
|
||||
$body = " \$this->{$variable} = new {$targetClass}();\n";
|
||||
}
|
||||
|
||||
return <<<PHP
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
{$body}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateFeatureSetupMethod(): string
|
||||
{
|
||||
return <<<PHP
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
\$this->withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json'
|
||||
]);
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateApiSetupMethod(): string
|
||||
{
|
||||
return <<<PHP
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
\$this->withHeaders([
|
||||
'Accept' => 'application/json',
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer test-token'
|
||||
]);
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function generateUnitTestBody(string $method, string $targetClass, array $options): string
|
||||
{
|
||||
$variable = $this->getVariableName($targetClass);
|
||||
$body = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'create':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$data = [
|
||||
'name' => 'Test Name',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
// Act
|
||||
\$result = \$this->{$variable}->create(\$data);
|
||||
|
||||
// Assert
|
||||
\$this->assertIsArray(\$result);
|
||||
\$this->assertArrayHasKey('id', \$result);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'getById':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$id = 1;
|
||||
|
||||
// Act
|
||||
\$result = \$this->{$variable}->getById(\$id);
|
||||
|
||||
// Assert
|
||||
\$this->assertIsArray(\$result);
|
||||
\$this->assertEquals(\$id, \$result['id']);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$id = 1;
|
||||
\$data = ['name' => 'Updated Name'];
|
||||
|
||||
// Act
|
||||
\$result = \$this->{$variable}->update(\$id, \$data);
|
||||
|
||||
// Assert
|
||||
\$this->assertIsArray(\$result);
|
||||
\$this->assertEquals('Updated Name', \$result['name']);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$id = 1;
|
||||
|
||||
// Act
|
||||
\$result = \$this->{$variable}->delete(\$id);
|
||||
|
||||
// Assert
|
||||
\$this->assertTrue(\$result);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
default:
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$input = 'test input';
|
||||
|
||||
// Act
|
||||
\$result = \$this->{$variable}->{$method}(\$input);
|
||||
|
||||
// Assert
|
||||
\$this->assertNotNull(\$result);
|
||||
PHP;
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function generateFeatureTestBody(string $method, string $targetClass, array $options): string
|
||||
{
|
||||
$resource = strtolower($this->getVariableName(str_replace('Controller', '', $targetClass)));
|
||||
$body = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'index':
|
||||
$body = <<<PHP
|
||||
// Act
|
||||
\$response = \$this->get('/{$resource}');
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJsonStructure([
|
||||
'*' => ['id', 'name']
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
|
||||
// Act
|
||||
\$response = \$this->get('/{$resource}/' . \$item->id);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJson([
|
||||
'id' => \$item->id
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'store':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$data = [
|
||||
'name' => 'Test Name',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
// Act
|
||||
\$response = \$this->post('/{$resource}', \$data);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(201);
|
||||
\$response->assertJson([
|
||||
'name' => 'Test Name'
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
\$data = ['name' => 'Updated Name'];
|
||||
|
||||
// Act
|
||||
\$response = \$this->put('/{$resource}/' . \$item->id, \$data);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJson([
|
||||
'name' => 'Updated Name'
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'destroy':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
|
||||
// Act
|
||||
\$response = \$this->delete('/{$resource}/' . \$item->id);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$this->assertDatabaseMissing('{$resource}s', ['id' => \$item->id]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
default:
|
||||
$body = <<<PHP
|
||||
// TODO: Implement test for {$method}
|
||||
\$this->assertTrue(true);
|
||||
PHP;
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function generateApiTestBody(string $method, string $targetClass, array $options): string
|
||||
{
|
||||
$resource = strtolower($this->getVariableName(str_replace('Controller', '', $targetClass)));
|
||||
$body = '';
|
||||
|
||||
switch ($method) {
|
||||
case 'index':
|
||||
$body = <<<PHP
|
||||
// Act
|
||||
\$response = \$this->get('/api/{$resource}');
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJsonStructure([
|
||||
'code',
|
||||
'message',
|
||||
'data' => [
|
||||
'*' => ['id', 'name']
|
||||
]
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'show':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
|
||||
// Act
|
||||
\$response = \$this->get('/api/{$resource}/' . \$item->id);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJson([
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'id' => \$item->id
|
||||
]
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'store':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$data = [
|
||||
'name' => 'Test Name',
|
||||
'email' => 'test@example.com'
|
||||
];
|
||||
|
||||
// Act
|
||||
\$response = \$this->post('/api/{$resource}', \$data);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(201);
|
||||
\$response->assertJson([
|
||||
'code' => 201,
|
||||
'message' => 'Resource created successfully',
|
||||
'data' => [
|
||||
'name' => 'Test Name'
|
||||
]
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
\$data = ['name' => 'Updated Name'];
|
||||
|
||||
// Act
|
||||
\$response = \$this->put('/api/{$resource}/' . \$item->id, \$data);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJson([
|
||||
'code' => 200,
|
||||
'message' => 'Resource updated successfully',
|
||||
'data' => [
|
||||
'name' => 'Updated Name'
|
||||
]
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
case 'destroy':
|
||||
$body = <<<PHP
|
||||
// Arrange
|
||||
\$item = \$this->createTest{$targetClass}();
|
||||
|
||||
// Act
|
||||
\$response = \$this->delete('/api/{$resource}/' . \$item->id);
|
||||
|
||||
// Assert
|
||||
\$response->assertStatus(200);
|
||||
\$response->assertJson([
|
||||
'code' => 200,
|
||||
'message' => 'Resource deleted successfully'
|
||||
]);
|
||||
PHP;
|
||||
break;
|
||||
|
||||
default:
|
||||
$body = <<<PHP
|
||||
// TODO: Implement API test for {$method}
|
||||
\$this->assertTrue(true);
|
||||
PHP;
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
|
||||
private function generateBasicTestBody(string $method, array $options): string
|
||||
{
|
||||
return <<<PHP
|
||||
// Arrange
|
||||
\$input = 'test input';
|
||||
|
||||
// Act
|
||||
\$result = \$this->performAction(\$input);
|
||||
|
||||
// Assert
|
||||
\$this->assertNotNull(\$result);
|
||||
\$this->assertEquals('expected', \$result);
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getTestMethodName(string $method): string
|
||||
{
|
||||
if (str_starts_with($method, 'test_')) {
|
||||
return $method;
|
||||
}
|
||||
|
||||
return 'test_' . $method;
|
||||
}
|
||||
|
||||
private function generateTestDocBlock(string $method, string $type, ?string $targetClass): string
|
||||
{
|
||||
$description = "Test {$method}";
|
||||
|
||||
if ($targetClass) {
|
||||
$description .= " in {$targetClass}";
|
||||
}
|
||||
|
||||
switch ($type) {
|
||||
case 'unit':
|
||||
$description .= " (unit test)";
|
||||
break;
|
||||
case 'feature':
|
||||
$description .= " (feature test)";
|
||||
break;
|
||||
case 'api':
|
||||
$description .= " (API test)";
|
||||
break;
|
||||
}
|
||||
|
||||
return "/**\n * {$description}.\n */";
|
||||
}
|
||||
|
||||
private function getTargetNamespace(string $target): string
|
||||
{
|
||||
// 根据目标类推断命名空间
|
||||
if (str_ends_with($target, 'Controller')) {
|
||||
return $this->getFullNamespace('Controller');
|
||||
} elseif (str_ends_with($target, 'Service')) {
|
||||
return $this->getFullNamespace('Service');
|
||||
} elseif (str_ends_with($target, 'Repository')) {
|
||||
return $this->getFullNamespace('Repository');
|
||||
} else {
|
||||
return $this->getFullNamespace('Model');
|
||||
}
|
||||
}
|
||||
|
||||
private function getDefaultMethodsForClass(string $targetClass): array
|
||||
{
|
||||
if (str_ends_with($targetClass, 'Controller')) {
|
||||
return ['index', 'show', 'store', 'update', 'destroy'];
|
||||
} elseif (str_ends_with($targetClass, 'Service')) {
|
||||
return ['getAll', 'getById', 'create', 'update', 'delete'];
|
||||
} elseif (str_ends_with($targetClass, 'Repository')) {
|
||||
return ['findAll', 'findById', 'create', 'update', 'delete'];
|
||||
} else {
|
||||
return ['test_example'];
|
||||
}
|
||||
}
|
||||
|
||||
private function getDefaultMethodsForFeature(string $targetClass): array
|
||||
{
|
||||
if (str_ends_with($targetClass, 'Controller')) {
|
||||
return ['index', 'show', 'store', 'update', 'destroy'];
|
||||
}
|
||||
return ['test_example'];
|
||||
}
|
||||
|
||||
private function getDefaultMethodsForApi(string $targetClass): array
|
||||
{
|
||||
if (str_ends_with($targetClass, 'Controller')) {
|
||||
return ['index', 'show', 'store', 'update', 'destroy'];
|
||||
}
|
||||
return ['test_example'];
|
||||
}
|
||||
|
||||
private function getTestTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends TestCase
|
||||
{
|
||||
{{setup_method}}{{test_methods}}}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getUnitTestTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends TestCase
|
||||
{
|
||||
{{properties}}{{setup_method}}{{test_methods}}}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getFeatureTestTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends TestCase
|
||||
{
|
||||
{{setup_method}}{{test_methods}}}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getApiTestTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {{namespace}};
|
||||
|
||||
{{use_block}}class {{class_name}} extends TestCase
|
||||
{
|
||||
{{setup_method}}{{test_methods}}}
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getTestMethodTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
{{doc_block}}
|
||||
public function {{method_name}}(): void
|
||||
{
|
||||
{{body}}
|
||||
}
|
||||
|
||||
PHP;
|
||||
}
|
||||
|
||||
private function getAssertionTemplate(): string
|
||||
{
|
||||
return <<<PHP
|
||||
// Assertion examples:
|
||||
// \$this->assertTrue(\$condition);
|
||||
// \$this->assertEquals(\$expected, \$actual);
|
||||
// \$this->assertArrayHasKey(\$key, \$array);
|
||||
// \$this->assertDatabaseHas(\$table, \$data);
|
||||
// \$response->assertStatus(\$status);
|
||||
// \$response->assertJson(\$data);
|
||||
PHP;
|
||||
}
|
||||
}
|
||||
330
fendx-framework/fendx-cli/src/Input/ArgvInput.php
Normal file
330
fendx-framework/fendx-cli/src/Input/ArgvInput.php
Normal file
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Input;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
use Fendx\CLI\Exception\RuntimeException;
|
||||
|
||||
class ArgvInput implements InputInterface
|
||||
{
|
||||
private array $tokens;
|
||||
private array $parsed = [];
|
||||
private ?InputDefinition $definition = null;
|
||||
private array $arguments = [];
|
||||
private array $options = [];
|
||||
private bool $interactive = true;
|
||||
|
||||
public function __construct(array $argv = null)
|
||||
{
|
||||
$argv = $argv ?? $_SERVER['argv'] ?? [];
|
||||
|
||||
// 移除脚本名称
|
||||
array_shift($argv);
|
||||
|
||||
$this->tokens = $argv;
|
||||
}
|
||||
|
||||
public function getFirstArgument(): ?string
|
||||
{
|
||||
foreach ($this->tokens as $token) {
|
||||
if ($token && $token[0] !== '-') {
|
||||
return $token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function hasParameterOption(array $options, bool $onlyParams = false): bool
|
||||
{
|
||||
foreach ($this->tokens as $token) {
|
||||
if ($onlyParams && $token === '--') {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($token === $option || str_starts_with($token, $option . '=')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getParameterOption(array $options, $default = false, bool $onlyParams = false)
|
||||
{
|
||||
foreach ($this->tokens as $i => $token) {
|
||||
if ($onlyParams && $token === '--') {
|
||||
return $default;
|
||||
}
|
||||
|
||||
foreach ($options as $option) {
|
||||
if ($token === $option) {
|
||||
return $this->tokens[$i + 1] ?? true;
|
||||
}
|
||||
|
||||
if (str_starts_with($token, $option . '=')) {
|
||||
return substr($token, strlen($option) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function bind(InputDefinition $definition): void
|
||||
{
|
||||
$this->definition = $definition;
|
||||
$this->parse();
|
||||
}
|
||||
|
||||
public function validate(): void
|
||||
{
|
||||
if ($this->definition === null) {
|
||||
throw new RuntimeException('Input definition must be bound before validation.');
|
||||
}
|
||||
|
||||
$missingArguments = [];
|
||||
|
||||
foreach ($this->definition->getArguments() as $argument) {
|
||||
if ($argument->isRequired() && !$this->hasArgument($argument->getName())) {
|
||||
$missingArguments[] = $argument->getName();
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($missingArguments)) {
|
||||
throw new RuntimeException(sprintf('Not enough arguments (missing: "%s").', implode('", "', $missingArguments)));
|
||||
}
|
||||
}
|
||||
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->arguments;
|
||||
}
|
||||
|
||||
public function getArgument(string $name)
|
||||
{
|
||||
if (!$this->definition) {
|
||||
throw new RuntimeException('Input definition must be bound before getting arguments.');
|
||||
}
|
||||
|
||||
if (!$this->definition->hasArgument($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" argument does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->arguments[$name] ?? $this->definition->getArgument($name)->getDefault();
|
||||
}
|
||||
|
||||
public function setArgument(string $name, $value): void
|
||||
{
|
||||
if (!$this->definition) {
|
||||
throw new RuntimeException('Input definition must be bound before setting arguments.');
|
||||
}
|
||||
|
||||
if (!$this->definition->hasArgument($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" argument does not exist.', $name));
|
||||
}
|
||||
|
||||
$this->arguments[$name] = $value;
|
||||
}
|
||||
|
||||
public function hasArgument(string $name): bool
|
||||
{
|
||||
return array_key_exists($name, $this->arguments);
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
public function getOption(string $name)
|
||||
{
|
||||
if (!$this->definition) {
|
||||
throw new RuntimeException('Input definition must be bound before getting options.');
|
||||
}
|
||||
|
||||
if (!$this->definition->hasOption($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" option does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->options[$name] ?? $this->definition->getOption($name)->getDefault();
|
||||
}
|
||||
|
||||
public function setOption(string $name, $value): void
|
||||
{
|
||||
if (!$this->definition) {
|
||||
throw new RuntimeException('Input definition must be bound before setting options.');
|
||||
}
|
||||
|
||||
if (!$this->definition->hasOption($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" option does not exist.', $name));
|
||||
}
|
||||
|
||||
$this->options[$name] = $value;
|
||||
}
|
||||
|
||||
public function hasOption(string $name): bool
|
||||
{
|
||||
return array_key_exists($name, $this->options);
|
||||
}
|
||||
|
||||
public function isInteractive(): bool
|
||||
{
|
||||
return $this->interactive;
|
||||
}
|
||||
|
||||
public function setInteractive(bool $interactive): void
|
||||
{
|
||||
$this->interactive = $interactive;
|
||||
}
|
||||
|
||||
private function parse(): void
|
||||
{
|
||||
if (empty($this->tokens)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->parsed = $this->tokens;
|
||||
$this->arguments = [];
|
||||
$this->options = [];
|
||||
|
||||
$parseOptions = true;
|
||||
$token = current($this->parsed);
|
||||
|
||||
while ($token !== false) {
|
||||
$nextToken = next($this->parsed);
|
||||
|
||||
if ($parseOptions && $token === '--') {
|
||||
$parseOptions = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($parseOptions && str_starts_with($token, '--')) {
|
||||
$this->parseLongOption($token);
|
||||
} elseif ($parseOptions && $token[0] === '-' && $token !== '-') {
|
||||
$this->parseShortOption($token);
|
||||
} elseif ($parseOptions) {
|
||||
$this->parseArgument($token);
|
||||
} else {
|
||||
$this->parseArgument($token);
|
||||
}
|
||||
|
||||
$token = $nextToken;
|
||||
}
|
||||
}
|
||||
|
||||
private function parseLongOption(string $token): void
|
||||
{
|
||||
$name = substr($token, 2);
|
||||
|
||||
if (str_contains($name, '=')) {
|
||||
[$name, $value] = explode('=', $name, 2);
|
||||
$this->addLongOption($name, $value);
|
||||
} else {
|
||||
$this->addLongOption($name, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseShortOption(string $token): void
|
||||
{
|
||||
$name = substr($token, 1);
|
||||
|
||||
if (strlen($name) > 1) {
|
||||
if ($this->definition->hasShortcut($name[0]) && $this->definition->getOptionForShortcut($name[0])->acceptValue()) {
|
||||
// 短选项接受值
|
||||
$this->addShortOption($name[0], substr($name, 1));
|
||||
} else {
|
||||
// 多个短选项
|
||||
for ($i = 0; $i < strlen($name); $i++) {
|
||||
$this->addShortOption($name[$i], true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$this->addShortOption($name, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function parseArgument(string $token): void
|
||||
{
|
||||
$c = count($this->arguments);
|
||||
|
||||
if ($this->definition->hasArgument($c)) {
|
||||
$arg = $this->definition->getArgument($c);
|
||||
$this->arguments[$arg->getName()] = $token;
|
||||
} elseif ($this->definition->hasArgument($c) && $this->definition->getArgument($c)->isArray()) {
|
||||
$arg = $this->definition->getArgument($c);
|
||||
$this->arguments[$arg->getName()][] = $token;
|
||||
} elseif ($this->definition->hasArgument('command')) {
|
||||
$this->arguments['command'] = $token;
|
||||
}
|
||||
}
|
||||
|
||||
private function addLongOption(string $name, $value): void
|
||||
{
|
||||
if (!$this->definition->hasOption($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "--%s" option does not exist.', $name));
|
||||
}
|
||||
|
||||
$option = $this->definition->getOption($name);
|
||||
|
||||
if ($value === true && !$option->acceptValue()) {
|
||||
$value = $option->getDefault() ?? true;
|
||||
}
|
||||
|
||||
if ($option->isArray()) {
|
||||
$this->options[$name][] = $value;
|
||||
} else {
|
||||
$this->options[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
private function addShortOption(string $shortcut, $value): void
|
||||
{
|
||||
if (!$this->definition->hasShortcut($shortcut)) {
|
||||
throw new InvalidArgumentException(sprintf('The "-%s" option does not exist.', $shortcut));
|
||||
}
|
||||
|
||||
$option = $this->definition->getOptionForShortcut($shortcut);
|
||||
$name = $option->getName();
|
||||
|
||||
if ($value === true && !$option->acceptValue()) {
|
||||
$value = $option->getDefault() ?? true;
|
||||
}
|
||||
|
||||
if ($option->isArray()) {
|
||||
$this->options[$name][] = $value;
|
||||
} else {
|
||||
$this->options[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return implode(' ', $this->tokens);
|
||||
}
|
||||
|
||||
public function getTokens(): array
|
||||
{
|
||||
return $this->tokens;
|
||||
}
|
||||
|
||||
public function setTokens(array $tokens): void
|
||||
{
|
||||
$this->tokens = $tokens;
|
||||
$this->parsed = [];
|
||||
$this->arguments = [];
|
||||
$this->options = [];
|
||||
}
|
||||
|
||||
public function escapeToken(string $token): string
|
||||
{
|
||||
if (preg_match('{^[\w-]+$}', $token)) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
return escapeshellarg($token);
|
||||
}
|
||||
}
|
||||
100
fendx-framework/fendx-cli/src/Input/InputArgument.php
Normal file
100
fendx-framework/fendx-cli/src/Input/InputArgument.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Input;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
|
||||
class InputArgument
|
||||
{
|
||||
public const REQUIRED = 1;
|
||||
public const OPTIONAL = 2;
|
||||
public const IS_ARRAY = 4;
|
||||
|
||||
private string $name;
|
||||
private int $mode;
|
||||
private string $description;
|
||||
private $default;
|
||||
|
||||
public function __construct(string $name, int $mode = null, string $description = '', $default = null)
|
||||
{
|
||||
if ($mode === null) {
|
||||
$mode = self::OPTIONAL;
|
||||
} elseif ($mode > 7 || $mode < 1) {
|
||||
throw new InvalidArgumentException(sprintf('Argument mode "%s" is not valid.', $mode));
|
||||
}
|
||||
|
||||
$this->name = $name;
|
||||
$this->mode = $mode;
|
||||
$this->description = $description;
|
||||
|
||||
$this->setDefault($default);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function isRequired(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::REQUIRED);
|
||||
}
|
||||
|
||||
public function isOptional(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::OPTIONAL);
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::IS_ARRAY);
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getDefault()
|
||||
{
|
||||
return $this->default;
|
||||
}
|
||||
|
||||
public function setDefault($default = null): void
|
||||
{
|
||||
if ($this->isRequired() && $default !== null) {
|
||||
throw new InvalidArgumentException('Cannot set a default value for required arguments.');
|
||||
}
|
||||
|
||||
if ($this->isArray()) {
|
||||
if ($default === null) {
|
||||
$default = [];
|
||||
} elseif (!is_array($default)) {
|
||||
throw new InvalidArgumentException('A default value for an array argument must be an array.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->default = $default;
|
||||
}
|
||||
|
||||
public function getSynopsis(): string
|
||||
{
|
||||
$synopsis = $this->name;
|
||||
|
||||
if ($this->isArray()) {
|
||||
$synopsis .= '...';
|
||||
}
|
||||
|
||||
if (!$this->isRequired()) {
|
||||
$synopsis = '[' . $synopsis . ']';
|
||||
}
|
||||
|
||||
return $synopsis;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getSynopsis();
|
||||
}
|
||||
}
|
||||
179
fendx-framework/fendx-cli/src/Input/InputDefinition.php
Normal file
179
fendx-framework/fendx-cli/src/Input/InputDefinition.php
Normal file
@@ -0,0 +1,179 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Input;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
|
||||
class InputDefinition
|
||||
{
|
||||
private array $arguments = [];
|
||||
private array $options = [];
|
||||
private array $shortcuts = [];
|
||||
|
||||
public function __construct(array $definitions = [])
|
||||
{
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition instanceof InputArgument) {
|
||||
$this->addArgument($definition);
|
||||
} elseif ($definition instanceof InputOption) {
|
||||
$this->addOption($definition);
|
||||
} else {
|
||||
throw new InvalidArgumentException('Input definition must be an instance of InputArgument or InputOption.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function addArgument(InputArgument $argument): void
|
||||
{
|
||||
if (isset($this->arguments[$argument->getName()])) {
|
||||
throw new InvalidArgumentException(sprintf('An argument with name "%s" already exists.', $argument->getName()));
|
||||
}
|
||||
|
||||
$this->arguments[$argument->getName()] = $argument;
|
||||
}
|
||||
|
||||
public function addOption(InputOption $option): void
|
||||
{
|
||||
if (isset($this->options[$option->getName()])) {
|
||||
throw new InvalidArgumentException(sprintf('An option with name "%s" already exists.', $option->getName()));
|
||||
}
|
||||
|
||||
if ($option->getShortcut()) {
|
||||
foreach (explode('|', $option->getShortcut()) as $shortcut) {
|
||||
if (isset($this->shortcuts[$shortcut])) {
|
||||
throw new InvalidArgumentException(sprintf('An option with shortcut "%s" already exists.', $shortcut));
|
||||
}
|
||||
$this->shortcuts[$shortcut] = $option->getName();
|
||||
}
|
||||
}
|
||||
|
||||
$this->options[$option->getName()] = $option;
|
||||
}
|
||||
|
||||
public function getArguments(): array
|
||||
{
|
||||
return $this->arguments;
|
||||
}
|
||||
|
||||
public function getArgument(string $name): InputArgument
|
||||
{
|
||||
if (!$this->hasArgument($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" argument does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->arguments[$name];
|
||||
}
|
||||
|
||||
public function hasArgument(string $name): bool
|
||||
{
|
||||
return isset($this->arguments[$name]);
|
||||
}
|
||||
|
||||
public function getArgumentCount(): int
|
||||
{
|
||||
return count($this->arguments);
|
||||
}
|
||||
|
||||
public function getArgumentRequiredCount(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->arguments as $argument) {
|
||||
if ($argument->isRequired()) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
{
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
public function getOption(string $name): InputOption
|
||||
{
|
||||
if (!$this->hasOption($name)) {
|
||||
throw new InvalidArgumentException(sprintf('The "%s" option does not exist.', $name));
|
||||
}
|
||||
|
||||
return $this->options[$name];
|
||||
}
|
||||
|
||||
public function hasOption(string $name): bool
|
||||
{
|
||||
return isset($this->options[$name]);
|
||||
}
|
||||
|
||||
public function hasShortcut(string $shortcut): bool
|
||||
{
|
||||
return isset($this->shortcuts[$shortcut]);
|
||||
}
|
||||
|
||||
public function getOptionForShortcut(string $shortcut): InputOption
|
||||
{
|
||||
if (!$this->hasShortcut($shortcut)) {
|
||||
throw new InvalidArgumentException(sprintf('The "-%s" shortcut does not exist.', $shortcut));
|
||||
}
|
||||
|
||||
return $this->getOption($this->shortcuts[$shortcut]);
|
||||
}
|
||||
|
||||
public function getSynopsis(): string
|
||||
{
|
||||
$elements = [];
|
||||
|
||||
foreach ($this->getOptions() as $option) {
|
||||
if ($option->isRequired()) {
|
||||
$elements[] = sprintf('--%s', $option->getName());
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->getArguments() as $argument) {
|
||||
$elements[] = $argument->getName();
|
||||
}
|
||||
|
||||
return implode(' ', $elements);
|
||||
}
|
||||
|
||||
public function setDefinition(array $definitions): void
|
||||
{
|
||||
$this->arguments = [];
|
||||
$this->options = [];
|
||||
$this->shortcuts = [];
|
||||
|
||||
foreach ($definitions as $definition) {
|
||||
if ($definition instanceof InputArgument) {
|
||||
$this->addArgument($definition);
|
||||
} elseif ($definition instanceof InputOption) {
|
||||
$this->addOption($definition);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function merge(self $definition): void
|
||||
{
|
||||
foreach ($definition->getArguments() as $argument) {
|
||||
$this->addArgument($argument);
|
||||
}
|
||||
|
||||
foreach ($definition->getOptions() as $option) {
|
||||
$this->addOption($option);
|
||||
}
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
$synopsis = '';
|
||||
|
||||
foreach ($this->getOptions() as $option) {
|
||||
$synopsis .= ' ' . $option->getSynopsis();
|
||||
}
|
||||
|
||||
foreach ($this->getArguments() as $argument) {
|
||||
$synopsis .= ' ' . $argument->getSynopsis();
|
||||
}
|
||||
|
||||
return trim($synopsis);
|
||||
}
|
||||
}
|
||||
37
fendx-framework/fendx-cli/src/Input/InputInterface.php
Normal file
37
fendx-framework/fendx-cli/src/Input/InputInterface.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Input;
|
||||
|
||||
interface InputInterface
|
||||
{
|
||||
public function getFirstArgument(): ?string;
|
||||
|
||||
public function hasParameterOption(array $options, bool $onlyParams = false): bool;
|
||||
|
||||
public function getParameterOption(array $options, $default = false, bool $onlyParams = false);
|
||||
|
||||
public function bind(InputDefinition $definition): void;
|
||||
|
||||
public function validate(): void;
|
||||
|
||||
public function getArguments(): array;
|
||||
|
||||
public function getArgument(string $name);
|
||||
|
||||
public function setArgument(string $name, $value): void;
|
||||
|
||||
public function hasArgument(string $name): bool;
|
||||
|
||||
public function getOptions(): array;
|
||||
|
||||
public function getOption(string $name);
|
||||
|
||||
public function setOption(string $name, $value): void;
|
||||
|
||||
public function hasOption(string $name): bool;
|
||||
|
||||
public function isInteractive(): bool;
|
||||
|
||||
public function setInteractive(bool $interactive): void;
|
||||
}
|
||||
121
fendx-framework/fendx-cli/src/Input/InputOption.php
Normal file
121
fendx-framework/fendx-cli/src/Input/InputOption.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Input;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
|
||||
class InputOption
|
||||
{
|
||||
public const VALUE_NONE = 1;
|
||||
public const VALUE_REQUIRED = 2;
|
||||
public const VALUE_OPTIONAL = 4;
|
||||
public const VALUE_IS_ARRAY = 8;
|
||||
|
||||
private string $name;
|
||||
private $shortcut;
|
||||
private int $mode;
|
||||
private string $description;
|
||||
private $default;
|
||||
|
||||
public function __construct(string $name, $shortcut = null, int $mode = null, string $description = '', $default = null)
|
||||
{
|
||||
if ($mode === null) {
|
||||
$mode = self::VALUE_NONE;
|
||||
} elseif ($mode > 15 || $mode < 1) {
|
||||
throw new InvalidArgumentException(sprintf('Option mode "%s" is not valid.', $mode));
|
||||
}
|
||||
|
||||
$this->name = $name;
|
||||
$this->shortcut = $shortcut;
|
||||
$this->mode = $mode;
|
||||
$this->description = $description;
|
||||
|
||||
if ($this->isArray() && !$this->acceptValue()) {
|
||||
throw new InvalidArgumentException('Impossible to have an option mode VALUE_IS_ARRAY if the option does not accept a value.');
|
||||
}
|
||||
|
||||
$this->setDefault($default);
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function getShortcut()
|
||||
{
|
||||
return $this->shortcut;
|
||||
}
|
||||
|
||||
public function acceptValue(): bool
|
||||
{
|
||||
return $this->isValueRequired() || $this->isValueOptional();
|
||||
}
|
||||
|
||||
public function isValueRequired(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::VALUE_REQUIRED);
|
||||
}
|
||||
|
||||
public function isValueOptional(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::VALUE_OPTIONAL);
|
||||
}
|
||||
|
||||
public function isArray(): bool
|
||||
{
|
||||
return (bool) ($this->mode & self::VALUE_IS_ARRAY);
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function getDefault()
|
||||
{
|
||||
return $this->default;
|
||||
}
|
||||
|
||||
public function setDefault($default = null): void
|
||||
{
|
||||
if ($this->isValueRequired() && $default === null) {
|
||||
throw new InvalidArgumentException('Cannot set a default value for a required option.');
|
||||
}
|
||||
|
||||
if ($this->isArray()) {
|
||||
if ($default === null) {
|
||||
$default = [];
|
||||
} elseif (!is_array($default)) {
|
||||
throw new InvalidArgumentException('A default value for an array option must be an array.');
|
||||
}
|
||||
}
|
||||
|
||||
$this->default = $default;
|
||||
}
|
||||
|
||||
public function getSynopsis(): string
|
||||
{
|
||||
$synopsis = '--' . $this->name;
|
||||
|
||||
if ($this->acceptValue()) {
|
||||
$synopsis .= '=VALUE';
|
||||
}
|
||||
|
||||
if ($this->isArray()) {
|
||||
$synopsis .= '...';
|
||||
}
|
||||
|
||||
if (!$this->isValueRequired()) {
|
||||
$synopsis = '[' . $synopsis . ']';
|
||||
}
|
||||
|
||||
return $synopsis;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->getSynopsis();
|
||||
}
|
||||
}
|
||||
350
fendx-framework/fendx-cli/src/Output/ConsoleOutput.php
Normal file
350
fendx-framework/fendx-cli/src/Output/ConsoleOutput.php
Normal file
@@ -0,0 +1,350 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
use Fendx\CLI\Output\OutputInterface;
|
||||
use Fendx\CLI\Output\OutputFormatterInterface;
|
||||
use Fendx\CLI\Output\OutputFormatter;
|
||||
|
||||
class ConsoleOutput implements OutputInterface
|
||||
{
|
||||
public const VERBOSITY_QUIET = 16;
|
||||
public const VERBOSITY_NORMAL = 32;
|
||||
public const VERBOSITY_VERBOSE = 64;
|
||||
public const VERBOSITY_VERY_VERBOSE = 128;
|
||||
public const VERBOSITY_DEBUG = 256;
|
||||
|
||||
public const OUTPUT_NORMAL = 0;
|
||||
public const OUTPUT_RAW = 1;
|
||||
public const OUTPUT_PLAIN = 2;
|
||||
|
||||
private int $verbosity;
|
||||
private bool $decorated;
|
||||
private OutputFormatterInterface $formatter;
|
||||
|
||||
public function __construct(int $verbosity = self::VERBOSITY_NORMAL, bool $decorated = null, OutputFormatterInterface $formatter = null)
|
||||
{
|
||||
$this->verbosity = $verbosity;
|
||||
$this->decorated = $decorated ?? $this->isDecoratedSupported();
|
||||
$this->formatter = $formatter ?? new OutputFormatter();
|
||||
}
|
||||
|
||||
public function write(string|array $messages, bool $newline = false, int $options = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
if ($this->verbosity === self::VERBOSITY_QUIET) {
|
||||
return;
|
||||
}
|
||||
|
||||
$messages = (array) $messages;
|
||||
|
||||
foreach ($messages as $message) {
|
||||
if ($options & self::OUTPUT_RAW) {
|
||||
$line = $message;
|
||||
} elseif ($options & self::OUTPUT_PLAIN) {
|
||||
$line = strip_tags($this->formatter->format($message));
|
||||
} else {
|
||||
$line = $this->formatter->format($message);
|
||||
}
|
||||
|
||||
$this->doWrite($line, $newline);
|
||||
}
|
||||
}
|
||||
|
||||
public function writeln(string|array $messages, int $options = self::OUTPUT_NORMAL): void
|
||||
{
|
||||
$this->write($messages, true, $options);
|
||||
}
|
||||
|
||||
public function setVerbosity(int $level): void
|
||||
{
|
||||
$this->verbosity = $level;
|
||||
}
|
||||
|
||||
public function getVerbosity(): int
|
||||
{
|
||||
return $this->verbosity;
|
||||
}
|
||||
|
||||
public function isQuiet(): bool
|
||||
{
|
||||
return $this->verbosity === self::VERBOSITY_QUIET;
|
||||
}
|
||||
|
||||
public function isVerbose(): bool
|
||||
{
|
||||
return $this->verbosity >= self::VERBOSITY_VERBOSE;
|
||||
}
|
||||
|
||||
public function isVeryVerbose(): bool
|
||||
{
|
||||
return $this->verbosity >= self::VERBOSITY_VERY_VERBOSE;
|
||||
}
|
||||
|
||||
public function isDebug(): bool
|
||||
{
|
||||
return $this->verbosity >= self::VERBOSITY_DEBUG;
|
||||
}
|
||||
|
||||
public function setDecorated(bool $decorated): void
|
||||
{
|
||||
$this->decorated = $decorated;
|
||||
}
|
||||
|
||||
public function isDecorated(): bool
|
||||
{
|
||||
return $this->decorated;
|
||||
}
|
||||
|
||||
public function setFormatter(OutputFormatterInterface $formatter): void
|
||||
{
|
||||
$this->formatter = $formatter;
|
||||
}
|
||||
|
||||
public function getFormatter(): OutputFormatterInterface
|
||||
{
|
||||
return $this->formatter;
|
||||
}
|
||||
|
||||
protected function doWrite(string $message, bool $newline): void
|
||||
{
|
||||
echo $message;
|
||||
if ($newline) {
|
||||
echo PHP_EOL;
|
||||
}
|
||||
}
|
||||
|
||||
private function isDecoratedSupported(): bool
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
return (function_exists('sapi_windows_vt100_support') && sapi_windows_vt100_support(STDOUT))
|
||||
|| getenv('ANSICON') !== false
|
||||
|| getenv('ConEmuANSI') === 'ON'
|
||||
|| getenv('TERM') === 'xterm';
|
||||
}
|
||||
|
||||
return stream_isatty(STDOUT);
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
public function success(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
public function error(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
public function warning(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
public function info(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
public function comment(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
public function question(string|array $messages): void
|
||||
{
|
||||
$this->writeln($messages, self::OUTPUT_NORMAL);
|
||||
}
|
||||
|
||||
// 进度条相关方法
|
||||
public function startProgress(int $max = 0): void
|
||||
{
|
||||
$this->write('<info>Progress:</info> [');
|
||||
$this->progressCurrent = 0;
|
||||
$this->progressMax = $max;
|
||||
}
|
||||
|
||||
public function updateProgress(int $current): void
|
||||
{
|
||||
if (!isset($this->progressMax)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$percent = $this->progressMax > 0 ? ($current / $this->progressMax) * 100 : 0;
|
||||
$barLength = 50;
|
||||
$filledLength = (int) (($percent / 100) * $barLength);
|
||||
|
||||
$bar = str_repeat('=', $filledLength) . str_repeat(' ', $barLength - $filledLength);
|
||||
$this->write("\r<info>Progress:</info> [{$bar}] " . number_format($percent, 1) . '%');
|
||||
}
|
||||
|
||||
public function finishProgress(): void
|
||||
{
|
||||
$this->writeln("\r<info>Progress:</info> [=========================================] 100.0%");
|
||||
unset($this->progressCurrent, $this->progressMax);
|
||||
}
|
||||
|
||||
// 表格输出方法
|
||||
public function table(array $headers, array $rows): void
|
||||
{
|
||||
if (empty($rows)) {
|
||||
$this->writeln('<info>No data to display.</info>');
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算列宽
|
||||
$widths = [];
|
||||
foreach ($headers as $i => $header) {
|
||||
$widths[$i] = strlen(strip_tags($this->formatter->format($header)));
|
||||
}
|
||||
|
||||
foreach ($rows as $row) {
|
||||
foreach ($row as $i => $cell) {
|
||||
$widths[$i] = max($widths[$i], strlen(strip_tags($this->formatter->format((string) $cell))));
|
||||
}
|
||||
}
|
||||
|
||||
// 输出表头
|
||||
$headerLine = '|';
|
||||
$separatorLine = '+';
|
||||
foreach ($headers as $i => $header) {
|
||||
$paddedHeader = str_pad(strip_tags($this->formatter->format($header)), $widths[$i]);
|
||||
$headerLine .= ' ' . $paddedHeader . ' |';
|
||||
$separatorLine .= '-' . str_repeat('-', $widths[$i]) . '-+';
|
||||
}
|
||||
|
||||
$this->writeln($separatorLine);
|
||||
$this->writeln($headerLine);
|
||||
$this->writeln($separatorLine);
|
||||
|
||||
// 输出数据行
|
||||
foreach ($rows as $row) {
|
||||
$rowLine = '|';
|
||||
foreach ($row as $i => $cell) {
|
||||
$paddedCell = str_pad(strip_tags($this->formatter->format((string) $cell)), $widths[$i]);
|
||||
$rowLine .= ' ' . $paddedCell . ' |';
|
||||
}
|
||||
$this->writeln($rowLine);
|
||||
}
|
||||
|
||||
$this->writeln($separatorLine);
|
||||
}
|
||||
|
||||
// 清屏方法
|
||||
public function clear(): void
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
system('cls');
|
||||
} else {
|
||||
system('clear');
|
||||
}
|
||||
}
|
||||
|
||||
// 新行方法
|
||||
public function newLine(int $count = 1): void
|
||||
{
|
||||
$this->write(str_repeat(PHP_EOL, $count));
|
||||
}
|
||||
|
||||
// 确认对话框
|
||||
public function confirm(string $question, bool $default = true): bool
|
||||
{
|
||||
$this->write("<question>{$question}</question> ");
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
return $answer === '' ? $default : strtolower($answer[0]) === 'y';
|
||||
}
|
||||
|
||||
// 询问输入
|
||||
public function ask(string $question, $default = null): string
|
||||
{
|
||||
$this->write("<question>{$question}</question> ");
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
return $answer === '' ? (string) $default : $answer;
|
||||
}
|
||||
|
||||
// 密码输入
|
||||
public function askHidden(string $question): string
|
||||
{
|
||||
$this->write("<question>{$question}</question> ");
|
||||
|
||||
// 隐藏输入
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
// Windows
|
||||
system('cls');
|
||||
} else {
|
||||
// Unix-like
|
||||
system('stty -echo');
|
||||
}
|
||||
|
||||
$password = trim(fgets(STDIN));
|
||||
|
||||
if (DIRECTORY_SEPARATOR !== '\\') {
|
||||
system('stty echo');
|
||||
}
|
||||
|
||||
$this->writeln('');
|
||||
|
||||
return $password;
|
||||
}
|
||||
|
||||
// 选择菜单
|
||||
public function choice(string $question, array $choices, $default = null): string
|
||||
{
|
||||
$this->writeln("<question>{$question}</question>");
|
||||
|
||||
foreach ($choices as $key => $choice) {
|
||||
$this->writeln(" [<info>{$key}</info>] {$choice}");
|
||||
}
|
||||
|
||||
$this->write('> ');
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
if ($answer === '' && $default !== null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
if (!isset($choices[$answer])) {
|
||||
$this->error('Invalid choice.');
|
||||
return $this->choice($question, $choices, $default);
|
||||
}
|
||||
|
||||
return $answer;
|
||||
}
|
||||
|
||||
// 多选菜单
|
||||
public function multiChoice(string $question, array $choices, array $defaults = []): array
|
||||
{
|
||||
$this->writeln("<question>{$question}</question> (comma separated)");
|
||||
|
||||
foreach ($choices as $key => $choice) {
|
||||
$selected = in_array($key, $defaults) ? '<info>✓</info>' : ' ';
|
||||
$this->writeln(" [{$selected}] [<info>{$key}</info>] {$choice}");
|
||||
}
|
||||
|
||||
$this->write('> ');
|
||||
$answer = trim(fgets(STDIN));
|
||||
|
||||
if ($answer === '') {
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
$selected = array_map('trim', explode(',', $answer));
|
||||
$valid = [];
|
||||
|
||||
foreach ($selected as $key) {
|
||||
if (isset($choices[$key])) {
|
||||
$valid[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($valid)) {
|
||||
$this->error('No valid choices selected.');
|
||||
return $this->multiChoice($question, $choices, $defaults);
|
||||
}
|
||||
|
||||
return $valid;
|
||||
}
|
||||
}
|
||||
126
fendx-framework/fendx-cli/src/Output/OutputFormatter.php
Normal file
126
fendx-framework/fendx-cli/src/Output/OutputFormatter.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
|
||||
class OutputFormatter implements OutputFormatterInterface
|
||||
{
|
||||
private bool $decorated;
|
||||
private array $styles = [];
|
||||
private string $styleStack;
|
||||
|
||||
public function __construct(bool $decorated = null, array $styles = [])
|
||||
{
|
||||
$this->decorated = $decorated ?? $this->isDecoratedSupported();
|
||||
$this->setStyle('error', new OutputFormatterStyle('white', 'red'));
|
||||
$this->setStyle('info', new OutputFormatterStyle('green'));
|
||||
$this->setStyle('comment', new OutputFormatterStyle('yellow'));
|
||||
$this->setStyle('question', new OutputFormatterStyle('black', 'cyan'));
|
||||
|
||||
foreach ($styles as $name => $style) {
|
||||
$this->setStyle($name, $style);
|
||||
}
|
||||
|
||||
$this->styleStack = '';
|
||||
}
|
||||
|
||||
public function format(string $message): string
|
||||
{
|
||||
if (!$this->isDecorated()) {
|
||||
return strip_tags($message);
|
||||
}
|
||||
|
||||
return preg_replace_callback($this->getRegex(), [$this, 'replaceStyle'], $message);
|
||||
}
|
||||
|
||||
public function setStyle(string $name, OutputFormatterStyleInterface $style): void
|
||||
{
|
||||
$this->styles[strtolower($name)] = $style;
|
||||
}
|
||||
|
||||
public function hasStyle(string $name): bool
|
||||
{
|
||||
return isset($this->styles[strtolower($name)]);
|
||||
}
|
||||
|
||||
public function getStyle(string $name): OutputFormatterStyleInterface
|
||||
{
|
||||
if (!$this->hasStyle($name)) {
|
||||
throw new InvalidArgumentException(sprintf('Undefined style: %s', $name));
|
||||
}
|
||||
|
||||
return $this->styles[strtolower($name)];
|
||||
}
|
||||
|
||||
public function isDecorated(): bool
|
||||
{
|
||||
return $this->decorated;
|
||||
}
|
||||
|
||||
public function setDecorated(bool $decorated): void
|
||||
{
|
||||
$this->decorated = $decorated;
|
||||
}
|
||||
|
||||
private function replaceStyle(array $match): string
|
||||
{
|
||||
if ($match[1] === '/') {
|
||||
// 结束标签
|
||||
$this->styleStack = substr($this->styleStack, 0, -strlen($match[2]));
|
||||
} else {
|
||||
// 开始标签
|
||||
$this->styleStack .= $match[2];
|
||||
}
|
||||
|
||||
return $this->getCurrentStyle();
|
||||
}
|
||||
|
||||
private function getCurrentStyle(): string
|
||||
{
|
||||
if (empty($this->styleStack)) {
|
||||
return $this->resetStyle();
|
||||
}
|
||||
|
||||
$styles = explode('>', $this->styleStack);
|
||||
$style = new OutputFormatterStyle();
|
||||
|
||||
foreach ($styles as $s) {
|
||||
if (empty($s)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$style = $this->getStyle($s)->apply($style);
|
||||
}
|
||||
|
||||
return $style->apply('') . $style->reset();
|
||||
}
|
||||
|
||||
private function getRegex(): string
|
||||
{
|
||||
return '/<(([\/]?)([a-z][a-z0-9_-]*))>/i';
|
||||
}
|
||||
|
||||
private function resetStyle(): string
|
||||
{
|
||||
return "\033[39m\033[49m";
|
||||
}
|
||||
|
||||
private function isDecoratedSupported(): bool
|
||||
{
|
||||
if (DIRECTORY_SEPARATOR === '\\') {
|
||||
return (function_exists('sapi_windows_vt100_support') && sapi_windows_vt100_support(STDOUT))
|
||||
|| getenv('ANSICON') !== false
|
||||
|| getenv('ConEmuANSI') === 'ON'
|
||||
|| getenv('TERM') === 'xterm';
|
||||
}
|
||||
|
||||
return stream_isatty(STDOUT);
|
||||
}
|
||||
|
||||
public function createStyleStack(): OutputFormatterStyleStack
|
||||
{
|
||||
return new OutputFormatterStyleStack($this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
interface OutputFormatterInterface
|
||||
{
|
||||
public function format(string $message): string;
|
||||
|
||||
public function setStyle(string $name, OutputFormatterStyleInterface $style): void;
|
||||
|
||||
public function hasStyle(string $name): bool;
|
||||
|
||||
public function getStyle(string $name): OutputFormatterStyleInterface;
|
||||
|
||||
public function isDecorated(): bool;
|
||||
|
||||
public function setDecorated(bool $decorated): void;
|
||||
}
|
||||
176
fendx-framework/fendx-cli/src/Output/OutputFormatterStyle.php
Normal file
176
fendx-framework/fendx-cli/src/Output/OutputFormatterStyle.php
Normal file
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
use Fendx\CLI\Exception\InvalidArgumentException;
|
||||
|
||||
class OutputFormatterStyle implements OutputFormatterStyleInterface
|
||||
{
|
||||
private static array $availableForegroundColors = [
|
||||
'black' => '30',
|
||||
'red' => '31',
|
||||
'green' => '32',
|
||||
'yellow' => '33',
|
||||
'blue' => '34',
|
||||
'magenta' => '35',
|
||||
'cyan' => '36',
|
||||
'white' => '37',
|
||||
'default' => '39',
|
||||
];
|
||||
|
||||
private static array $availableBackgroundColors = [
|
||||
'black' => '40',
|
||||
'red' => '41',
|
||||
'green' => '42',
|
||||
'yellow' => '43',
|
||||
'blue' => '44',
|
||||
'magenta' => '45',
|
||||
'cyan' => '46',
|
||||
'white' => '47',
|
||||
'default' => '49',
|
||||
];
|
||||
|
||||
private static array $availableOptions = [
|
||||
'bold' => '1',
|
||||
'underscore' => '4',
|
||||
'blink' => '5',
|
||||
'reverse' => '7',
|
||||
'conceal' => '8',
|
||||
];
|
||||
|
||||
private ?string $foreground;
|
||||
private ?string $background;
|
||||
private array $options;
|
||||
|
||||
public function __construct(string $foreground = null, string $background = null, array $options = [])
|
||||
{
|
||||
$this->foreground = $this->parseColor($foreground, self::$availableForegroundColors);
|
||||
$this->background = $this->parseColor($background, self::$availableBackgroundColors);
|
||||
$this->options = $this->parseOptions($options);
|
||||
}
|
||||
|
||||
public function apply(string $text): string
|
||||
{
|
||||
$codes = array_merge(
|
||||
$this->foreground ? [$this->foreground] : [],
|
||||
$this->background ? [$this->background] : [],
|
||||
$this->options
|
||||
);
|
||||
|
||||
if (empty($codes)) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
return sprintf("\033[%sm%s\033[%sm", implode(';', $codes), $text, $this->reset());
|
||||
}
|
||||
|
||||
public function setBackground(string $color = null): self
|
||||
{
|
||||
$this->background = $this->parseColor($color, self::$availableBackgroundColors);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setForeground(string $color = null): self
|
||||
{
|
||||
$this->foreground = $this->parseColor($color, self::$availableForegroundColors);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setOption(string $option): self
|
||||
{
|
||||
if (!isset(self::$availableOptions[$option])) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::$availableOptions))));
|
||||
}
|
||||
|
||||
if (!in_array(self::$availableOptions[$option], $this->options)) {
|
||||
$this->options[] = self::$availableOptions[$option];
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function unsetOption(string $option): self
|
||||
{
|
||||
if (!isset(self::$availableOptions[$option])) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::$availableOptions))));
|
||||
}
|
||||
|
||||
$code = self::$availableOptions[$option];
|
||||
$key = array_search($code, $this->options);
|
||||
if ($key !== false) {
|
||||
unset($this->options[$key]);
|
||||
$this->options = array_values($this->options);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setOptions(array $options): self
|
||||
{
|
||||
$this->options = $this->parseOptions($options);
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function reset(): string
|
||||
{
|
||||
return '0';
|
||||
}
|
||||
|
||||
private function parseColor(?string $color, array $availableColors): ?string
|
||||
{
|
||||
if ($color === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isset($availableColors[$color])) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid "%s" color specified: "%s". Expected one of (%s).', $color === self::$availableBackgroundColors ? 'background' : 'foreground', $color, implode(', ', array_keys($availableColors))));
|
||||
}
|
||||
|
||||
return $availableColors[$color];
|
||||
}
|
||||
|
||||
private function parseOptions(array $options): array
|
||||
{
|
||||
$codes = [];
|
||||
foreach ($options as $option) {
|
||||
if (!isset(self::$availableOptions[$option])) {
|
||||
throw new InvalidArgumentException(sprintf('Invalid option specified: "%s". Expected one of (%s).', $option, implode(', ', array_keys(self::$availableOptions))));
|
||||
}
|
||||
$codes[] = self::$availableOptions[$option];
|
||||
}
|
||||
|
||||
return $codes;
|
||||
}
|
||||
|
||||
public static function addColor(string $name, string $code): void
|
||||
{
|
||||
self::$availableForegroundColors[$name] = $code;
|
||||
self::$availableBackgroundColors[$name] = $code + 10;
|
||||
}
|
||||
|
||||
public static function addOption(string $name, string $code): void
|
||||
{
|
||||
self::$availableOptions[$name] = $code;
|
||||
}
|
||||
|
||||
public function getForeground(): ?string
|
||||
{
|
||||
return array_search($this->foreground, self::$availableForegroundColors) ?: null;
|
||||
}
|
||||
|
||||
public function getBackground(): ?string
|
||||
{
|
||||
return array_search($this->background, self::$availableBackgroundColors) ?: null;
|
||||
}
|
||||
|
||||
public function getOptions(): array
|
||||
{
|
||||
$options = [];
|
||||
foreach ($this->options as $code) {
|
||||
$options[] = array_search($code, self::$availableOptions);
|
||||
}
|
||||
|
||||
return array_filter($options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
interface OutputFormatterStyleInterface
|
||||
{
|
||||
public function apply(string $text): string;
|
||||
|
||||
public function setBackground(string $color = null): self;
|
||||
|
||||
public function setForeground(string $color = null): self;
|
||||
|
||||
public function setOption(string $option): self;
|
||||
|
||||
public function unsetOption(string $option): self;
|
||||
|
||||
public function setOptions(array $options): self;
|
||||
|
||||
public function reset(): string;
|
||||
}
|
||||
31
fendx-framework/fendx-cli/src/Output/OutputInterface.php
Normal file
31
fendx-framework/fendx-cli/src/Output/OutputInterface.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Fendx\CLI\Output;
|
||||
|
||||
interface OutputInterface
|
||||
{
|
||||
public function write(string|array $messages, bool $newline = false, int $options = 0): void;
|
||||
|
||||
public function writeln(string|array $messages, int $options = 0): void;
|
||||
|
||||
public function setVerbosity(int $level): void;
|
||||
|
||||
public function getVerbosity(): int;
|
||||
|
||||
public function isQuiet(): bool;
|
||||
|
||||
public function isVerbose(): bool;
|
||||
|
||||
public function isVeryVerbose(): bool;
|
||||
|
||||
public function isDebug(): bool;
|
||||
|
||||
public function setDecorated(bool $decorated): void;
|
||||
|
||||
public function isDecorated(): bool;
|
||||
|
||||
public function setFormatter(OutputFormatterInterface $formatter): void;
|
||||
|
||||
public function getFormatter(): OutputFormatterInterface;
|
||||
}
|
||||
Reference in New Issue
Block a user