feat(database): 添加用户角色权限系统及相关监控功能

- 创建用户表(users)包含基本信息和认证字段
- 创建角色表(roles)用于权限控制
- 创建权限表(permissions)定义系统权限
- 创建用户角色关联表(user_roles)建立用户与角色关系
- 创建角色权限关联表(role_permissions)建立角色与权限关系
- 创建迁移记录表(migrations)追踪数据库变更
- 添加AdminController提供管理员面板功能
- 实现系统监控、配置管理、缓存清理等功能
- 添加AOP切面编程支持的各种通知类型
- 实现告警管理AlertManager支持多渠道告警
- 添加文档注解接口规范
This commit is contained in:
Lawson
2026-04-08 17:00:28 +08:00
commit 2782d765fb
270 changed files with 107192 additions and 0 deletions

View 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
}

View 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('');
}
}
}

View 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);
}
}

View 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;
}

View 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);
}
}
}
}

View 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;
}
}

View 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;
}
}

View 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';
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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;
}

View 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();
}
}

View 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;
}
}

View 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);
}
}

View File

@@ -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;
}

View 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);
}
}

View File

@@ -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;
}

View 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;
}