|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +// API doc generator: walks src/, extracts docblocks + signatures via PhpToken, |
| 6 | +// emits one Markdown file per top-level namespace dir (plus index.md). |
| 7 | +// Usage: composer docs:api (or: php scripts/gen-api-docs.php) |
| 8 | + |
| 9 | +$root = dirname(__DIR__); |
| 10 | +$srcDir = $root . '/src'; |
| 11 | +$outDir = $root . '/docs/api'; |
| 12 | + |
| 13 | +if (!is_dir($srcDir)) { |
| 14 | + fwrite(STDERR, "src/ not found at {$srcDir}\n"); |
| 15 | + exit(1); |
| 16 | +} |
| 17 | +if (!is_dir($outDir) && !mkdir($outDir, 0o755, true) && !is_dir($outDir)) { |
| 18 | + fwrite(STDERR, "failed to create {$outDir}\n"); |
| 19 | + exit(1); |
| 20 | +} |
| 21 | + |
| 22 | +/** @return list<string> */ |
| 23 | +function php_files(string $dir): array |
| 24 | +{ |
| 25 | + $out = []; |
| 26 | + $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); |
| 27 | + foreach ($it as $f) { |
| 28 | + if ($f->isFile() && $f->getExtension() === 'php') { |
| 29 | + $out[] = $f->getPathname(); |
| 30 | + } |
| 31 | + } |
| 32 | + sort($out); |
| 33 | + return $out; |
| 34 | +} |
| 35 | + |
| 36 | +function clean_doc(string $raw): string |
| 37 | +{ |
| 38 | + $raw = preg_replace('#^/\*\*|\*/$#', '', trim($raw)) ?? ''; |
| 39 | + $lines = preg_split('/\R/', $raw) ?: []; |
| 40 | + $out = []; |
| 41 | + foreach ($lines as $l) { |
| 42 | + $out[] = rtrim(preg_replace('/^\s*\*\s?/', '', $l) ?? $l); |
| 43 | + } |
| 44 | + return trim(implode("\n", $out)); |
| 45 | +} |
| 46 | + |
| 47 | +/** @return array{namespace:string,decls:list<array<string,mixed>>} */ |
| 48 | +function parse_file(string $path): array |
| 49 | +{ |
| 50 | + $tokens = PhpToken::tokenize(file_get_contents($path) ?: ''); |
| 51 | + $ns = ''; |
| 52 | + $decls = []; |
| 53 | + $doc = null; |
| 54 | + $n = count($tokens); |
| 55 | + |
| 56 | + for ($i = 0; $i < $n; $i++) { |
| 57 | + $t = $tokens[$i]; |
| 58 | + |
| 59 | + if ($t->id === T_DOC_COMMENT) { |
| 60 | + $doc = clean_doc($t->text); |
| 61 | + continue; |
| 62 | + } |
| 63 | + if ($t->id === T_WHITESPACE || $t->id === T_COMMENT) { |
| 64 | + continue; |
| 65 | + } |
| 66 | + |
| 67 | + if ($t->id === T_NAMESPACE) { |
| 68 | + $name = ''; |
| 69 | + for ($j = $i + 1; $j < $n; $j++) { |
| 70 | + $tj = $tokens[$j]; |
| 71 | + if (in_array($tj->id, [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED], true)) { |
| 72 | + $name .= $tj->text; |
| 73 | + } elseif ($tj->text === ';' || $tj->text === '{') { |
| 74 | + break; |
| 75 | + } |
| 76 | + } |
| 77 | + $ns = trim($name, '\\'); |
| 78 | + $doc = null; |
| 79 | + continue; |
| 80 | + } |
| 81 | + |
| 82 | + if (in_array($t->id, [T_CLASS, T_INTERFACE, T_TRAIT, T_ENUM], true)) { |
| 83 | + // skip `new class { ... }` |
| 84 | + $p = $i - 1; |
| 85 | + while ($p >= 0 && $tokens[$p]->id === T_WHITESPACE) { |
| 86 | + $p--; |
| 87 | + } |
| 88 | + if ($p >= 0 && $tokens[$p]->id === T_NEW) { |
| 89 | + continue; |
| 90 | + } |
| 91 | + $kind = str_replace('t_', '', strtolower(token_name($t->id))); |
| 92 | + $name = ''; |
| 93 | + for ($j = $i + 1; $j < $n; $j++) { |
| 94 | + if ($tokens[$j]->id === T_STRING) { |
| 95 | + $name = $tokens[$j]->text; |
| 96 | + break; |
| 97 | + } |
| 98 | + } |
| 99 | + if ($name === '') { |
| 100 | + continue; |
| 101 | + } |
| 102 | + $decls[] = ['kind' => $kind, 'name' => $name, 'doc' => $doc ?? '', 'members' => []]; |
| 103 | + $doc = null; |
| 104 | + continue; |
| 105 | + } |
| 106 | + |
| 107 | + if ($t->id === T_FUNCTION) { |
| 108 | + // collect leading modifiers |
| 109 | + $mods = []; |
| 110 | + $b = $i - 1; |
| 111 | + while ($b >= 0) { |
| 112 | + $tb = $tokens[$b]; |
| 113 | + if ($tb->id === T_WHITESPACE) { |
| 114 | + $b--; |
| 115 | + continue; |
| 116 | + } |
| 117 | + if (in_array($tb->id, [T_PUBLIC, T_PROTECTED, T_PRIVATE, T_STATIC, T_ABSTRACT, T_FINAL, T_READONLY], true)) { |
| 118 | + $mods[] = strtolower($tb->text); |
| 119 | + $b--; |
| 120 | + continue; |
| 121 | + } |
| 122 | + break; |
| 123 | + } |
| 124 | + $mods = array_reverse($mods); |
| 125 | + |
| 126 | + // function name |
| 127 | + $j = $i + 1; |
| 128 | + while ($j < $n && $tokens[$j]->id === T_WHITESPACE) { |
| 129 | + $j++; |
| 130 | + } |
| 131 | + if ($j >= $n || $tokens[$j]->id !== T_STRING) { |
| 132 | + $doc = null; |
| 133 | + continue; // closure / arrow fn |
| 134 | + } |
| 135 | + $fname = $tokens[$j]->text; |
| 136 | + |
| 137 | + // params |
| 138 | + $k = $j + 1; |
| 139 | + while ($k < $n && $tokens[$k]->text !== '(') { |
| 140 | + $k++; |
| 141 | + } |
| 142 | + $depth = 0; |
| 143 | + $params = ''; |
| 144 | + for (; $k < $n; $k++) { |
| 145 | + $params .= $tokens[$k]->text; |
| 146 | + if ($tokens[$k]->text === '(') { |
| 147 | + $depth++; |
| 148 | + } elseif ($tokens[$k]->text === ')' && --$depth === 0) { |
| 149 | + $k++; |
| 150 | + break; |
| 151 | + } |
| 152 | + } |
| 153 | + // return type until { or ; |
| 154 | + $ret = ''; |
| 155 | + for (; $k < $n; $k++) { |
| 156 | + if ($tokens[$k]->text === '{' || $tokens[$k]->text === ';') { |
| 157 | + break; |
| 158 | + } |
| 159 | + $ret .= $tokens[$k]->text; |
| 160 | + } |
| 161 | + $sig = preg_replace('/\s+/', ' ', trim(implode(' ', $mods) . ' function ' . $fname . $params . $ret)); |
| 162 | + |
| 163 | + if (!empty($decls)) { |
| 164 | + $decls[count($decls) - 1]['members'][] = ['name' => $fname, 'sig' => $sig, 'doc' => $doc ?? '']; |
| 165 | + } else { |
| 166 | + $decls[] = ['kind' => 'function', 'name' => $fname, 'sig' => $sig, 'doc' => $doc ?? '', 'members' => []]; |
| 167 | + } |
| 168 | + $doc = null; |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + return ['namespace' => $ns, 'decls' => $decls]; |
| 173 | +} |
| 174 | + |
| 175 | +function render_decl(array $d): string |
| 176 | +{ |
| 177 | + $out = "### `" . $d['kind'] . ' ' . $d['name'] . "`\n\n"; |
| 178 | + if (!empty($d['doc'])) { |
| 179 | + $out .= $d['doc'] . "\n\n"; |
| 180 | + } |
| 181 | + if ($d['kind'] === 'function' && isset($d['sig'])) { |
| 182 | + $out .= "```php\n" . $d['sig'] . "\n```\n\n"; |
| 183 | + } |
| 184 | + foreach ($d['members'] as $m) { |
| 185 | + $out .= "#### `" . $m['name'] . "`\n\n```php\n" . $m['sig'] . "\n```\n\n"; |
| 186 | + if (!empty($m['doc'])) { |
| 187 | + $out .= $m['doc'] . "\n\n"; |
| 188 | + } |
| 189 | + } |
| 190 | + return $out; |
| 191 | +} |
| 192 | + |
| 193 | +// Group by top-level namespace dir under src/ |
| 194 | +$files = php_files($srcDir); |
| 195 | +$groups = []; |
| 196 | +$rootDecls = []; |
| 197 | +foreach ($files as $path) { |
| 198 | + $rel = substr($path, strlen($srcDir) + 1); |
| 199 | + $parsed = parse_file($path); |
| 200 | + $parsed['_rel'] = $rel; |
| 201 | + if (str_contains($rel, '/')) { |
| 202 | + $groups[explode('/', $rel, 2)[0]][] = $parsed; |
| 203 | + } else { |
| 204 | + $rootDecls[] = $parsed; |
| 205 | + } |
| 206 | +} |
| 207 | +ksort($groups); |
| 208 | + |
| 209 | +$index = "# PHP SDK API Reference\n\n"; |
| 210 | +$index .= "Auto-generated from PHPDoc blocks in `src/`. Regenerate with `composer docs:api`.\n\n"; |
| 211 | +$index .= "## Namespaces\n\n"; |
| 212 | + |
| 213 | +foreach ($groups as $top => $entries) { |
| 214 | + $slug = strtolower($top); |
| 215 | + usort($entries, fn ($a, $b) => strcmp($a['_rel'], $b['_rel'])); |
| 216 | + $content = "# Arcp\\{$top}\n\n"; |
| 217 | + foreach ($entries as $e) { |
| 218 | + if (empty($e['decls'])) { |
| 219 | + continue; |
| 220 | + } |
| 221 | + $content .= "## `" . $e['namespace'] . "` — `" . $e['_rel'] . "`\n\n"; |
| 222 | + foreach ($e['decls'] as $d) { |
| 223 | + $content .= render_decl($d); |
| 224 | + } |
| 225 | + } |
| 226 | + file_put_contents($outDir . '/' . $slug . '.md', $content); |
| 227 | + $index .= "- [Arcp\\{$top}](./{$slug}.md)\n"; |
| 228 | +} |
| 229 | + |
| 230 | +if (!empty($rootDecls)) { |
| 231 | + $content = "# Arcp (root)\n\n"; |
| 232 | + foreach ($rootDecls as $e) { |
| 233 | + if (empty($e['decls'])) { |
| 234 | + continue; |
| 235 | + } |
| 236 | + $content .= "## `" . $e['namespace'] . "` — `" . $e['_rel'] . "`\n\n"; |
| 237 | + foreach ($e['decls'] as $d) { |
| 238 | + $content .= render_decl($d); |
| 239 | + } |
| 240 | + } |
| 241 | + file_put_contents($outDir . '/_root.md', $content); |
| 242 | + $index .= "- [Arcp (root)](./_root.md)\n"; |
| 243 | +} |
| 244 | + |
| 245 | +file_put_contents($outDir . '/index.md', $index); |
| 246 | +$count = count($groups) + (empty($rootDecls) ? 0 : 1) + 1; |
| 247 | +echo "Wrote {$count} markdown files to docs/api/\n"; |
0 commit comments