/** * Docs Command * Generates documentation (DocBlock or OpenAPI) for PHP code */ import chalk from 'chalk'; import ora from 'ora'; import { writeFile, mkdir } from 'fs/promises'; import { resolve, basename, join } from 'path'; import { scanPhpFiles, extractClassInfo, type PhpFile } from '../scanner.js'; import { OllamaClient } from '../ollama.js'; /** * Options for the docs command */ export interface DocsOptions { format: 'docblock' | 'openapi'; output?: string; model: string; } /** * Documented file info */ interface DocumentedFile { original: string; output: string; } /** * Execute the docs command * @param targetPath - Path to PHP file(s) to document * @param options - Command options */ export async function docsCommand( targetPath: string, options: DocsOptions ): Promise { const spinner = ora('Initializing...').start(); try { // Initialize Ollama client const ollama = new OllamaClient({ model: options.model }); // Check Ollama connection spinner.text = 'Checking Ollama connection...'; const isConnected = await ollama.checkConnection(); if (!isConnected) { spinner.fail(chalk.red('Cannot connect to Ollama')); console.log(chalk.yellow('\nMake sure Ollama is running:')); console.log(chalk.gray(' 1. Install Ollama: https://ollama.ai')); console.log(chalk.gray(' 2. Start server: ollama serve')); console.log(chalk.gray(` 3. Pull model: ollama pull ${options.model}`)); process.exit(1); } // Scan for PHP files spinner.text = 'Scanning PHP files...'; const files = await scanPhpFiles(targetPath); if (files.length === 0) { spinner.fail(chalk.red('No PHP files found')); process.exit(1); } spinner.succeed(chalk.green(`Found ${files.length} PHP file(s)`)); // Route to appropriate handler based on format if (options.format === 'openapi') { await generateOpenAPI(files, ollama, options); } else { await generateDocBlocks(files, ollama, options); } } catch (error) { const err = error as Error; spinner.fail(chalk.red(`Error: ${err.message}`)); process.exit(1); } } /** * Generate DocBlock comments for PHP files * @param files - PHP files to document * @param ollama - Ollama client * @param options - Command options */ async function generateDocBlocks( files: PhpFile[], ollama: OllamaClient, options: DocsOptions ): Promise { console.log(chalk.cyan('\nGenerating DocBlock documentation...\n')); const documented: DocumentedFile[] = []; for (let i = 0; i < files.length; i++) { const file = files[i]; const spinner = ora(`Documenting ${file.name} (${i + 1}/${files.length})...`).start(); try { // Generate DocBlocks using AI let docCode = await ollama.generateDocBlocks(file.content); // Clean up the response docCode = cleanPhpCode(docCode); // Determine output path let outputPath: string; if (options.output) { // Output to specified directory const outputDir = resolve(options.output); await mkdir(outputDir, { recursive: true }); outputPath = join(outputDir, file.name); } else { // Create .documented.php file alongside original outputPath = file.path.replace('.php', '.documented.php'); } await writeFile(outputPath, docCode, 'utf-8'); documented.push({ original: file.name, output: outputPath, }); spinner.succeed(chalk.green(`Documented ${file.name}`)); } catch (error) { const err = error as Error; spinner.fail(chalk.red(`Failed to document ${file.name}: ${err.message}`)); } } // Summary console.log(chalk.green(`\n✓ Documented ${documented.length} file(s)`)); console.log(chalk.cyan('\nGenerated Files:')); for (const item of documented) { console.log(chalk.gray(` • ${item.original} → ${basename(item.output)}`)); } console.log(chalk.yellow('\nNext steps:')); console.log(chalk.gray(' 1. Review generated DocBlocks')); console.log(chalk.gray(' 2. Copy approved changes to original files')); console.log(chalk.gray(' 3. Run: vendor/bin/phpstan analyze (if using PHPStan)')); } /** * Generate OpenAPI specification from PHP controllers * @param files - PHP files to analyze * @param ollama - Ollama client * @param options - Command options */ async function generateOpenAPI( files: PhpFile[], ollama: OllamaClient, options: DocsOptions ): Promise { console.log(chalk.cyan('\nGenerating OpenAPI specification...\n')); // Combine all files for context const combinedCode = files .map((f) => `// File: ${f.name}\n${f.content}`) .join('\n\n// ---\n\n'); const spinner = ora('Analyzing API endpoints...').start(); try { // Generate OpenAPI spec using AI let spec = await ollama.generateOpenAPI(combinedCode); // Clean up the response spec = cleanYamlSpec(spec); // Determine output path (default to docs/ folder) const outputDir = resolve('./docs'); await mkdir(outputDir, { recursive: true }); const outputPath = options.output ? resolve(options.output) : join(outputDir, 'openapi.yaml'); await writeFile(outputPath, spec, 'utf-8'); spinner.succeed(chalk.green('Generated OpenAPI specification')); console.log(chalk.green(`\n✓ Specification saved to: ${outputPath}`)); console.log(chalk.cyan('\nFiles analyzed:')); for (const file of files) { const classInfo = extractClassInfo(file.content); const methods = classInfo.methods.filter((m) => m.visibility === 'public'); console.log(chalk.gray(` • ${file.name} (${methods.length} public methods)`)); } const fileName = basename(outputPath); console.log(chalk.yellow('\nNext steps:')); console.log(chalk.gray(' 1. Review generated specification')); console.log(chalk.gray(' 2. Add missing schemas and examples')); console.log(chalk.gray(` 3. Validate: npx @redocly/cli lint ${fileName}`)); console.log(chalk.gray(` 4. Build docs: npx @redocly/cli build-docs ${fileName} -o docs.html`)); } catch (error) { const err = error as Error; spinner.fail(chalk.red(`Failed to generate OpenAPI spec: ${err.message}`)); } } /** * Clean PHP code from AI response * Removes markdown code blocks, intro text, and trailing notes * @param code - Raw AI response * @returns Clean PHP code */ function cleanPhpCode(code: string): string { let cleaned = code .replace(/```php\n?/gi, '') .replace(/```\n?/g, '') .trim(); // Find the first 0) { cleaned = cleaned.substring(phpTagIndex); } // Remove any text after the last closing brace of the class const lastBraceIndex = cleaned.lastIndexOf('}'); if (lastBraceIndex !== -1) { cleaned = cleaned.substring(0, lastBraceIndex + 1); } if (!cleaned.startsWith('= 0; i--) { const line = lines[i].trim(); // Skip empty lines if (line === '') continue; // If line starts with common explanation starters, it's likely text if (/^(This|Note|The|These|Here|I |In |It |As |For |\d+\.|-)/.test(line)) { lastValidLine = i - 1; continue; } // If we find a valid YAML line, stop break; } // Remove trailing empty lines and text cleaned = lines.slice(0, lastValidLine + 1).join('\n').trim(); // Ensure it starts with openapi if (!cleaned.startsWith('openapi:')) { cleaned = 'openapi: 3.0.3\n' + cleaned; } return cleaned; }