Files
proofdb/vendor/webman/console/src/Commands/Concerns/MakeCommandHelpers.php
T
2026-05-01 23:40:14 +08:00

502 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Webman\Console\Commands\Concerns;
use Webman\Console\Messages;
use Webman\Console\Util;
trait MakeCommandHelpers
{
/**
* Check whether a plugin exists by config value.
*
* The existence rule is: config("plugin.<name>") is not empty.
*
* @param string $plugin
* @return bool
*/
protected function pluginExists(string $plugin): bool
{
$plugin = trim($plugin);
if ($plugin === '') {
return false;
}
$cfg = config("plugin.$plugin");
if ($cfg === null) {
return false;
}
if (is_array($cfg)) {
return $cfg !== [];
}
if (is_string($cfg)) {
return trim($cfg) !== '';
}
return (bool)$cfg;
}
/**
* Extract plugin name from a relative path like "plugin/<name>/...".
*
* @param string|null $path
* @return string|null
*/
protected function extractPluginNameFromRelativePath(?string $path): ?string
{
$path = $this->normalizeOptionValue($path);
if (!$path) {
return null;
}
$path = $this->normalizeRelativePath($path);
if (preg_match('#^plugin/([^/]+)/#i', $path, $m)) {
$name = trim((string)($m[1] ?? ''));
return $name !== '' ? $name : null;
}
return null;
}
/**
* Validate plugin existence and output error message when missing.
*
* @param string|null $plugin
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @return bool
*/
protected function assertPluginExists(?string $plugin, \Symfony\Component\Console\Output\OutputInterface $output): bool
{
$plugin = $this->normalizeOptionValue($plugin);
if (!$plugin) {
return true;
}
if ($this->pluginExists($plugin)) {
return true;
}
$output->writeln($this->renderPluginNotExistsMessage($plugin));
return false;
}
/**
* @param string $plugin
* @return string
*/
protected function renderPluginNotExistsMessage(string $plugin): string
{
$plugin = trim($plugin);
$line1 = Util::selectByLocale([
'zh_CN' => "<error>插件不存在:</error> <comment>{plugin}</comment>",
'zh_TW' => "<error>外掛不存在:</error> <comment>{plugin}</comment>",
'en' => "<error>Plugin does not exist:</error> <comment>{plugin}</comment>",
'ja' => "<error>プラグインが存在しません:</error> <comment>{plugin}</comment>",
'ko' => "<error>플러그인이 존재하지 않습니다:</error> <comment>{plugin}</comment>",
'fr' => "<error>Le plugin n'existe pas :</error> <comment>{plugin}</comment>",
'de' => "<error>Plugin existiert nicht:</error> <comment>{plugin}</comment>",
'es' => "<error>El plugin no existe:</error> <comment>{plugin}</comment>",
'pt_BR' => "<error>O plugin não existe:</error> <comment>{plugin}</comment>",
'ru' => "<error>Плагин не существует:</error> <comment>{plugin}</comment>",
'vi' => "<error>Plugin không tồn tại:</error> <comment>{plugin}</comment>",
'tr' => "<error>Eklenti mevcut değil:</error> <comment>{plugin}</comment>",
'id' => "<error>Plugin tidak ada:</error> <comment>{plugin}</comment>",
'th' => "<error>ไม่พบปลั๊กอิน:</error> <comment>{plugin}</comment>",
]);
$line2 = Util::selectByLocale([
'zh_CN' => '请检查插件名是否输入正确,或确认插件已正确安装/启用。',
'zh_TW' => '請檢查外掛名稱是否輸入正確,或確認外掛已正確安裝/啟用。',
'en' => 'Please check the plugin name, or make sure the plugin is installed/enabled.',
'ja' => 'プラグイン名を確認するか、プラグインがインストール/有効化されていることを確認してください。',
'ko' => '플러그인 이름을 확인하거나, 플러그인이 설치/활성화되었는지 확인하세요.',
'fr' => "Vérifiez le nom du plugin ou assurez-vous qu'il est installé/activé.",
'de' => 'Bitte prüfen Sie den Plugin-Namen oder ob das Plugin installiert/aktiviert ist.',
'es' => 'Compruebe el nombre del plugin o asegúrese de que esté instalado/habilitado.',
'pt_BR' => 'Verifique o nome do plugin ou se o plugin está instalado/ativado.',
'ru' => 'Проверьте имя плагина или убедитесь, что плагин установлен/включён.',
'vi' => 'Hãy kiểm tra tên plugin hoặc đảm bảo plugin đã được cài đặt/bật.',
'tr' => 'Eklenti adını kontrol edin veya eklentinin kurulu/etkin olduğundan emin olun.',
'id' => 'Periksa nama plugin atau pastikan plugin sudah terpasang/diaktifkan.',
'th' => 'โปรดตรวจสอบชื่อปลั๊กอิน หรือยืนยันว่าปลั๊กอินถูกติดตั้ง/เปิดใช้งานแล้ว',
]);
return strtr($line1, ['{plugin}' => $plugin]) . "\n" . $line2;
}
/**
* Symfony short options on some environments may return value like "=foo" for "-p=foo".
* Normalize to "foo".
*
* @param mixed $value
* @return string|null
*/
protected function normalizeOptionValue(mixed $value): ?string
{
if ($value === null) {
return null;
}
$value = trim((string)$value);
$value = ltrim($value, '=');
return $value === '' ? null : $value;
}
/**
* Normalize a relative path to use "/" separators and no leading/trailing slashes.
*
* @param string $path
* @return string
*/
protected function normalizeRelativePath(string $path): string
{
$path = trim($path);
$path = str_replace('\\', '/', $path);
$path = preg_replace('#^\\./+#', '', $path);
$path = trim($path, '/');
return $path;
}
/**
* @param string $path
* @return bool
*/
protected function isAbsolutePath(string $path): bool
{
$path = trim($path);
if ($path === '') {
return false;
}
// Windows drive letter, UNC path, or root slash.
if (preg_match('/^[a-zA-Z]:[\\\\\\/]/', $path)) {
return true;
}
if (str_starts_with($path, '\\\\') || str_starts_with($path, '//')) {
return true;
}
return str_starts_with($path, '/') || str_starts_with($path, '\\');
}
/**
* Compare two relative paths on Windows-friendly rules.
*
* @param string $a
* @param string $b
* @return bool
*/
protected function pathsEqual(string $a, string $b): bool
{
$a = strtolower($this->normalizeRelativePath($a));
$b = strtolower($this->normalizeRelativePath($b));
return $a === $b;
}
/**
* Convert an absolute path to a workspace-relative path for nicer CLI output.
*
* @param string $path
* @return string
*/
protected function toRelativePath(string $path): string
{
$base = base_path();
$baseNorm = rtrim(str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $base), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$pathNorm = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $path);
if (str_starts_with(strtolower($pathNorm), strtolower($baseNorm))) {
$rel = substr($pathNorm, strlen($baseNorm));
} else {
$rel = $pathNorm;
}
// Use forward slashes for nicer CLI output.
return str_replace(DIRECTORY_SEPARATOR, '/', $rel);
}
/**
* Get current locale for CLI messages. Delegates to Util::getLocale().
*
* @return string
*/
protected function getLocale(): string
{
return Util::getLocale();
}
/**
* Ask a question with Ctrl+C safety.
*
* On Windows, Ctrl+C during a prompt can corrupt the console code page,
* causing subsequent UTF-8 output to display as garbled text.
* This method restores the code page after every prompt to prevent that.
*
* @param \Symfony\Component\Console\Input\InputInterface $input
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @param \Symfony\Component\Console\Question\Question $question
* @return mixed
*/
protected function askOrAbort(
\Symfony\Component\Console\Input\InputInterface $input,
\Symfony\Component\Console\Output\OutputInterface $output,
\Symfony\Component\Console\Question\Question $question
): mixed {
$cpBefore = function_exists('sapi_windows_cp_get') ? sapi_windows_cp_get() : null;
/** @var \Symfony\Component\Console\Helper\QuestionHelper $helper */
$helper = $this->getHelper('question');
try {
$value = $helper->ask($input, $output, $question);
} catch (\Throwable $e) {
$this->restoreConsoleCodePage($cpBefore);
throw $e;
}
$this->restoreConsoleCodePage($cpBefore);
if ($value === null || (is_string($value) && str_contains($value, "\x03"))) {
exit(130);
}
return $value;
}
/**
* Restore console code page on Windows after an interactive prompt.
* Ctrl+C can corrupt the code page; this resets it to what it was before.
*/
private function restoreConsoleCodePage(?int $codepage): void
{
if ($codepage !== null && function_exists('sapi_windows_cp_set')) {
sapi_windows_cp_set($codepage);
}
}
/**
* Resolve namespace/file path by --plugin/-p or --path/-P.
* - --path/-P: must be a relative path (to project root).
* - If both are provided, they must point to the same directory, otherwise it's an error.
*
* @param string $name Name like "admin/User"
* @param string|null $plugin
* @param string|null $path
* @param \Symfony\Component\Console\Output\OutputInterface $output
* @param callable(string):string $pluginDefaultPathResolver Returns relative dir like "plugin/admin/app/controller"
* @param callable(string,array<string,string>):string $msg Message resolver: fn($key,$replace)=>string
* @return array{0:string,1:string,2:string}|null [class, namespace, file]
*/
protected function resolveTargetByPluginOrPath(
string $name,
?string $plugin,
?string $path,
\Symfony\Component\Console\Output\OutputInterface $output,
callable $pluginDefaultPathResolver,
callable $msg
): ?array {
$pathNorm = $path ? $this->normalizeRelativePath($path) : null;
if ($pathNorm !== null && $this->isAbsolutePath($pathNorm)) {
$output->writeln($msg('invalid_path', ['{path}' => (string)$path]));
return null;
}
// Validate plugin existence (from --plugin/-p or inferred from --path/-P).
$pluginToCheck = $this->normalizeOptionValue($plugin) ?: $this->extractPluginNameFromRelativePath($pathNorm);
if ($pluginToCheck && !$this->assertPluginExists($pluginToCheck, $output)) {
return null;
}
$expected = null;
if ($plugin) {
$expected = $pluginDefaultPathResolver($plugin);
}
if ($plugin && $pathNorm) {
$pluginPrefix = 'plugin/' . $plugin . '/';
if (!str_starts_with(strtolower($pathNorm), strtolower($pluginPrefix))) {
$output->writeln(strtr(
Util::selectByLocale(Messages::getPluginPathConflictMessage()),
['{plugin}' => $plugin, '{path}' => $pathNorm]
));
return null;
}
}
$targetRel = $pathNorm ?: $expected;
if (!$targetRel) {
return null;
}
$targetDir = base_path($targetRel);
$namespaceRoot = trim(str_replace('/', '\\', $targetRel), '\\');
if (!($pos = strrpos($name, '/'))) {
$class = ucfirst($name);
$subPath = '';
} else {
$subPath = substr($name, 0, $pos);
$class = ucfirst(substr($name, $pos + 1));
}
$subDir = $subPath ? str_replace('/', DIRECTORY_SEPARATOR, $subPath) . DIRECTORY_SEPARATOR : '';
$file = $targetDir . DIRECTORY_SEPARATOR . $subDir . $class . '.php';
$namespace = $namespaceRoot . ($subPath ? '\\' . str_replace('/', '\\', $subPath) : '');
return [$class, $namespace, $file];
}
/**
* Load a php config file (return array).
*
* @param string $file
* @return array|null null when file exists but does not return an array or cannot be included
*/
protected function loadPhpConfigArray(string $file): ?array
{
if (!is_file($file)) {
return [];
}
try {
$data = include $file;
} catch (\Throwable) {
return null;
}
return is_array($data) ? $data : null;
}
/**
* Get a simple PHP header, preserving the top docblock if present.
*
* @param string $file
* @return string
*/
protected function getPhpHeaderWithDocblock(string $file): string
{
$default = "<?php\n\n";
if (!is_file($file)) {
return $default;
}
$content = file_get_contents($file);
if (!is_string($content) || $content === '') {
return $default;
}
if (preg_match('/\A<\?php\s*(\/\*\*[\s\S]*?\*\/\s*)/i', $content, $m)) {
$doc = rtrim($m[1]) . "\n\n";
return "<?php\n" . $doc;
}
return $default;
}
/**
* Ensure parent directory exists.
*
* @param string $file
* @return void
*/
protected function ensureParentDir(string $file): void
{
$dir = pathinfo($file, PATHINFO_DIRNAME);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
}
/**
* Add a class to a flat config list (return [ClassA::class, ...]).
*
* @param string $file
* @param string $classFqn e.g. app\bootstrap\Test
* @return bool changed or not
*/
protected function addClassToFlatClassListConfig(string $file, string $classFqn): bool
{
$config = $this->loadPhpConfigArray($file);
if ($config === null) {
return false;
}
$classFqn = ltrim(trim($classFqn), '\\');
if (in_array($classFqn, $config, true)) {
return false;
}
$config[] = $classFqn;
$this->ensureParentDir($file);
$header = $this->getPhpHeaderWithDocblock($file);
$body = $this->renderFlatClassListConfig($config);
file_put_contents($file, $header . $body);
return true;
}
/**
* Add a class to middleware config under empty key ''.
* Return value format:
* return [
* '' => [
* Foo::class,
* ],
* ];
*
* @param string $file
* @param string $classFqn e.g. app\middleware\StaticFile
* @return bool changed or not
*/
protected function addClassToMiddlewareConfig(string $file, string $classFqn): bool
{
$config = $this->loadPhpConfigArray($file);
if ($config === null) {
return false;
}
$classFqn = ltrim(trim($classFqn), '\\');
if (!array_key_exists('', $config)) {
// Put '' first for readability.
$config = ['' => []] + $config;
}
$list = $config[''];
if (!is_array($list)) {
$list = [];
}
if (in_array($classFqn, $list, true)) {
return false;
}
$list[] = $classFqn;
$config[''] = $list;
$this->ensureParentDir($file);
$header = $this->getPhpHeaderWithDocblock($file);
$body = $this->renderMiddlewareConfig($config);
file_put_contents($file, $header . $body);
return true;
}
/**
* @param array<int,string> $classes
* @return string
*/
protected function renderFlatClassListConfig(array $classes): string
{
$lines = [];
$lines[] = "return [";
foreach ($classes as $c) {
$c = ltrim((string)$c, '\\');
if ($c === '') {
continue;
}
$lines[] = " {$c}::class,";
}
$lines[] = "];\n";
return implode("\n", $lines);
}
/**
* @param array<string,mixed> $config
* @return string
*/
protected function renderMiddlewareConfig(array $config): string
{
$lines = [];
$lines[] = "return [";
foreach ($config as $key => $value) {
$keyExport = var_export((string)$key, true);
$lines[] = " {$keyExport} => [";
$list = is_array($value) ? $value : [];
foreach ($list as $c) {
$c = ltrim((string)$c, '\\');
if ($c === '') {
continue;
}
$lines[] = " {$c}::class,";
}
$lines[] = " ],";
}
$lines[] = "];\n";
return implode("\n", $lines);
}
}