diff --git a/bin/gendoc b/bin/gendoc new file mode 100755 index 00000000..b2c05312 --- /dev/null +++ b/bin/gendoc @@ -0,0 +1,396 @@ +#!/usr/bin/env php +getDocComment()) && strpos($doc, '@Annotation') !== false) { + return true; + } + if (str_ends_with($class->getName(), 'Exception')) { + return true; + } + if (str_contains($class->getName(), '_')) { + return true; + } + return false; +} + +if (PHP_VERSION_ID < 70400) { + echo 'PHP 版本必须为 7.4 或以上'; + exit(1); +} + +function check_composer_executable(string $composer): bool +{ + passthru($composer . ' --version 2>/dev/null', $exit_code); + return $exit_code === 0; +} + +function find_valid_root(string $current_dir, int $max_loop): string +{ + if ($max_loop <= 0) { + return ''; + } + if (!file_exists($current_dir . '/composer.json')) { + return find_valid_root(dirname($current_dir), $max_loop - 1); + } + return $current_dir; +} + +$root = find_valid_root(getcwd(), 3); +if (empty($root)) { + echo '找不到有效的根目录'; + exit(1); +} +echo '根目录: ' . $root . PHP_EOL; + +chdir($root); + +if (!is_dir($root . '/src/ZM')) { + echo '源码目录不存在'; + exit(1); +} + +$phpdoc_package_temp_installed = false; +if (file_exists($root . '/vendor/jasny/phpdoc-parser/composer.json')) { + echo '正在使用现有的 PHPDoc 解析库' . PHP_EOL; +} else { + echo 'PHPDoc 解析库不存在,正在尝试安装...' . PHP_EOL; + echo '本次生成完成后将会自动移除' . PHP_EOL; + $composers = [ + 'runtime/composer', 'composer', 'php composer.phar', + ]; + foreach ($composers as $composer) { + if (check_composer_executable($composer)) { + echo '正在使用 ' . $composer . ' 安装 PHPDoc 解析库,请稍候...' . PHP_EOL; + passthru("$composer require --quiet jasny/phpdoc-parser", $exit_code); + if ($exit_code === 0) { + $phpdoc_package_temp_installed = true; + break; + } + } + } + if (!$phpdoc_package_temp_installed) { + echo '安装失败,请手动安装:composer require jasny/phpdoc-parser' . PHP_EOL; + exit(1); + } +} + +// 从这里开始是主要生成逻辑 + +require_once $root . '/vendor/autoload.php'; + +use Jasny\PhpdocParser\PhpdocParser; +use Jasny\PhpdocParser\Set\PhpDocumentor; +use Jasny\PhpdocParser\Tag\Summery; +use ZM\Console\Console; + +$opts = getopt('', ['show-warnings']); +if (array_key_exists('show-warnings', $opts)) { + $show_warnings = true; +} else { + $show_warnings = false; +} + +Console::init(2); + +$errors = []; +$warnings = []; + +// 获取源码目录的文件遍历器 +$fs = new \RecursiveDirectoryIterator($root . '/src/ZM', FilesystemIterator::SKIP_DOTS); + +// 初始化文档解析器 +$parser = new PhpdocParser(PhpDocumentor::tags()->with([ + new Summery(), +])); + +$metas = []; +$class_count = 0; +$method_count = 0; + +function error(string $message) +{ + global $errors; + $errors[] = $message; + Console::error($message); +} + +function warning(string $message) +{ + global $warnings, $show_warnings; + $warnings[] = $message; + if ($show_warnings) { + Console::warning($message); + } +} + +/** + * 获取类的元数据 + * + * 包括类的注释、方法的注释、参数、返回值等 + */ +function get_class_metas(string $class_name, PhpdocParser $parser): array +{ + // 尝试获取反射类 + try { + $class = new \ReflectionClass($class_name); + } catch (\ReflectionException $e) { + error('无法获取类 ' . $class_name . ' 的反射类'); + return []; + } + + // 判断是否略过该类 + if (should_ignore_class($class)) { + return []; + } + + $metas = []; + + // 遍历类方法 + foreach ($class->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() !== $class_name) { + continue; + } + + Console::verbose('正在解析方法:' . $class_name . '::' . $method->getName()); + + // 获取方法的注释并解析 + $doc = $method->getDocComment(); + if (!$doc) { + warning('找不到文档:' . $class_name . '::' . $method->getName()); + continue; + } + try { + $meta = $parser->parse($doc); + } catch (\Exception $e) { + error('解析失败:' . $class_name . '::' . $method->getName() . ',' . $e->getMessage()); + continue; + } + // 少数情况解析后会带有 */,需要去除 + array_walk_recursive($meta, static function (&$item) { + if (is_string($item)) { + $item = trim(str_replace('*/', '', $item)); + } + }); + + // 对比反射方法获取的参数和注释声明的参数 + $parameters = $method->getParameters(); + $params_in_doc = $meta['params'] ?? []; + + foreach ($parameters as $parameter) { + $parameter_name = $parameter->getName(); + // 不存在则添加进参数列表中 + if (!isset($params_in_doc[$parameter_name])) { + $params_in_doc[$parameter_name] = [ + 'type' => $parameter->getType()?->getName(), + 'description' => '', + ]; + } + } + // 确保所有参数都有对应的类型和描述 + foreach ($params_in_doc as &$param) { + if (!isset($param['type'])) { + $param['type'] = 'mixed'; + } + if (!isset($param['description'])) { + $param['description'] = ''; + } + } + // 清除引用 + unset($param); + $meta['params'] = $params_in_doc; + + // 设定方法默认返回值 + if (!isset($meta['return'])) { + $meta['return'] = [ + 'type' => $method->getReturnType()?->getName() ?: 'mixed', + 'description' => '', + ]; + } + + // 设定默认描述 + if (!isset($meta['return']['description'])) { + $meta['return']['description'] = ''; + } + + $metas[$method->getName()] = $meta; + } + + return $metas; +} + +/** + * 将方法的元数据转换为 Markdown 格式 + * + * @param string $method 方法名 + * @param array $meta 元数据 + */ +function convert_meta_to_markdown(string $method, array $meta): string +{ + // 方法名作为标题 + $markdown = '## ' . $method . "\n\n"; + + // 构造方法代码块 + $markdown .= '```php' . "\n"; + // TODO: 适配 private 等修饰符 + $markdown .= 'public function ' . $method . '('; + $params = []; + // 添加参数 + foreach ($meta['params'] as $param_name => $param_meta) { + $params[] = sprintf('%s $%s', $param_meta['type'] ?? 'mixed', $param_name); + } + $markdown .= implode(', ', $params) . ')'; + // 添加返回值 + $markdown .= ': ' . $meta['return']['type']; + $markdown .= "\n```\n\n"; + + // 方法描述 + $markdown .= '### 描述' . "\n\n"; + $markdown .= ($meta['description'] ?? '作者很懒,什么也没有说') . "\n\n"; + + // 参数 + if (count($meta['params'])) { + $markdown .= '### 参数' . "\n\n"; + $markdown .= '| 名称 | 类型 | 描述 |' . "\n"; + $markdown .= '| -------- | ---- | ----------- |' . "\n"; + foreach ($meta['params'] as $param_name => $param_meta) { + $markdown .= '| ' . $param_name . ' | ' . $param_meta['type'] . ' | ' . $param_meta['description'] . ' |' . "\n"; + } + } + + // 返回值 + $markdown .= '### 返回' . "\n\n"; + $markdown .= '| 类型 | 描述 |' . "\n"; + $markdown .= '| ---- | ----------- |' . "\n"; + $markdown .= '| ' . $meta['return']['type'] . ' | ' . $meta['return']['description'] . ' |' . "\n"; + + return $markdown; +} + +function generate_sidebar_config(string $title, array $items): array +{ + return [ + 'title' => $title, + 'collapsable' => true, + 'children' => $items, + ]; +} + +Console::info('开始生成!'); + +// 遍历类并将元数据添加至数组中 +foreach (new \RecursiveIteratorIterator($fs) as $file) { + if (!$file->isFile()) { + continue; + } + $path = $file->getPathname(); + + // 过滤不包含类的文件 + $tokens = token_get_all(file_get_contents($path)); + $found = false; + foreach ($tokens as $token) { + if (!is_array($token)) { + continue; + } + if ($token[0] === T_CLASS) { + $found = true; + } + } + if (!$found) { + continue; + } + + // 获取完整类名 + $path = ltrim($path, $root . '/'); + $class = str_replace(['.php', 'src/', '/'], ['', '', '\\'], $path); + Console::verbose('正在解析类:' . $class); + $meta = get_class_metas($class, $parser); + // 忽略不包含任何方法的类 + if (empty($meta)) { + continue; + } + $metas[$class] = $meta; + ++$class_count; + $method_count += count($meta); +} + +// 将元数据转换为 Markdown +$markdown = []; +foreach ($metas as $class => $class_metas) { + $markdown[$class] = []; + // 将类名作为页面大标题 + $markdown[$class]['class'] = '# ' . $class; + foreach ($class_metas as $method => $meta) { + $markdown[$class][$method] = convert_meta_to_markdown($method, $meta); + } +} + +// 生成文档 +$docs = $root . '/docs/api/'; +foreach ($markdown as $class => $methods) { + $file = $docs . str_replace('\\', '/', $class) . '.md'; + // 确保目录存在 + if (!file_exists(dirname($file)) && !mkdir($concurrent_directory = dirname($file), 0777, true) && !is_dir($concurrent_directory)) { + throw new \RuntimeException(sprintf('无法建立目录 %s', $concurrent_directory)); + } + Console::verbose('正在生成文档:' . $file); + $text = implode("\n\n", $methods); + file_put_contents($file, $text); +} + +// 生成文档目录 +$children = array_keys($markdown); +$children = str_replace('\\', '/', $children); +$class_tree = []; +foreach ($children as $child) { + $parent = dirname($child); + $class_tree[$parent][] = $child; +} +ksort($class_tree); +$config = 'module.exports = ['; +foreach ($class_tree as $parent => $children) { + $encode = json_encode(generate_sidebar_config($parent, $children)); + $encode = str_replace('\/', '/', $encode); + $config .= $encode . ','; +} +$config = rtrim($config, ','); +$config .= ']'; + +$file = $root . '/docs/.vuepress/api.js'; +file_put_contents($file, $config); + +if (count($warnings)) { + Console::warning('生成过程中发现 ' . count($warnings) . ' 次警告'); + if (!$show_warnings) { + Console::info('生成器默认忽略了警告,你可以通过 gendoc --show-warnings 来查看警告'); + } +} +if (count($errors)) { + Console::error('生成过程中发现错误:'); + foreach ($errors as $error) { + Console::error($error); + } +} + +Console::success('API 文档生成完毕'); +Console::success(sprintf('共生成 %d 个类,共 %d 个方法', $class_count, $method_count)); + +// 完成生成,进行善后 + +if ($phpdoc_package_temp_installed) { + echo '正在移除 PHPDoc 解析库,请稍候...' . PHP_EOL; + passthru('composer remove --quiet jasny/phpdoc-parser', $exit_code); + if ($exit_code !== 0) { + echo '移除失败,请手动移除:composer remove jasny/phpdoc-parser' . PHP_EOL; + } + echo '移除完成'; +} +exit(0);