AstDocTransformer.php•18.3 kB
<?php
declare(strict_types=1);
namespace Butschster\ContextGenerator\Modifier\PhpDocs;
use Butschster\ContextGenerator\Modifier\SourceModifierInterface;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\EnumType;
use Nette\PhpGenerator\InterfaceType;
use Nette\PhpGenerator\Method;
use Nette\PhpGenerator\PhpFile;
use Nette\PhpGenerator\Property;
use Nette\PhpGenerator\TraitType;
/**
 * AstDocTransformer - A modifier that transforms PHP code into structured markdown documentation.
 *
 * This modifier uses nette/php-generator to parse PHP code and transform it into a
 * well-formatted markdown document with preserved code blocks for method implementations.
 */
final class AstDocTransformer implements SourceModifierInterface
{
    /**
     * Configuration options for the transformer
     *
     * @var array<string, mixed>
     */
    private array $config;
    public function __construct(array $config = [])
    {
        $this->config = \array_merge([
            // Include private methods in documentation
            'include_private_methods' => false,
            // Include protected methods in documentation
            'include_protected_methods' => false,
            // Include private properties in documentation
            'include_private_properties' => false,
            // Include protected properties in documentation
            'include_protected_properties' => false,
            // Include method implementations
            'include_implementations' => false,
            // Include property default values
            'include_property_defaults' => true,
            // Include constants
            'include_constants' => false,
            // Format for code blocks
            'code_block_format' => 'php',
            // Heading level for class names (1-6)
            'class_heading_level' => 2,
            // Whether to extract route annotations
            'extract_routes' => true,
            // Whether to keep doc comments
            'keep_doc_comments' => false,
        ], $config);
    }
    public function getIdentifier(): string
    {
        return 'php-docs';
    }
    public function supports(string $contentType): bool
    {
        return \str_ends_with($contentType, '.php');
    }
    public function modify(string $content, array $context = []): string
    {
        // Merge context configuration with defaults
        $this->config = \array_merge($this->config, $context);
        try {
            $file = PhpFile::fromCode($content);
            $output = '';
            foreach ($file->getNamespaces() as $namespace) {
                $namespaceName = $namespace->getName();
                // Process classes in this namespace
                foreach ($namespace->getClasses() as $class) {
                    $className = $class->getName();
                    $fullClassName = $namespaceName ? "$namespaceName\\$className" : $className;
                    // Process the appropriate type
                    if ($class instanceof InterfaceType) {
                        $output .= $this->processInterface($class, $fullClassName);
                    } elseif ($class instanceof TraitType) {
                        $output .= $this->processTrait($class, $fullClassName);
                    } elseif (PHP_VERSION_ID >= 80100 && $class instanceof EnumType) {
                        $output .= $this->processEnum($class, $fullClassName);
                    } else {
                        $output .= $this->processClass($class, $fullClassName);
                    }
                }
            }
            return $output;
        } catch (\Throwable $e) {
            return "// Error parsing file: {$e->getMessage()}\n";
        }
    }
    /**
     * Process a class and generate documentation
     */
    private function processClass(ClassType $class, string $fullClassName): string
    {
        $headingLevel = $this->config['class_heading_level'];
        $output = \str_repeat('#', $headingLevel) . " Class: $fullClassName\n\n";
        // Add class docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $class->getComment()) {
            $docComment = $this->formatDocComment($class->getComment());
            $output .= "$docComment\n\n";
        }
        // Process class metadata
        $metadata = [];
        if ($class->getExtends()) {
            $metadata[] = "**Extends:** `{$class->getExtends()}`";
        }
        if ($class->getImplements()) {
            $implements = \array_map(static fn($i) => "`$i`", $class->getImplements());
            $metadata[] = "**Implements:** " . \implode(', ', $implements);
        }
        if (!empty($metadata)) {
            $output .= \implode("\n", $metadata) . "\n\n";
        }
        // Process constants if enabled
        if ($this->config['include_constants'] && !empty($class->getConstants())) {
            $output .= "## Constants\n\n";
            foreach ($class->getConstants() as $constant) {
                $output .= $this->processConstant($constant);
            }
        }
        // Process properties
        $properties = $class->getProperties();
        if (!empty($properties)) {
            $output .= "## Properties\n\n";
            foreach ($properties as $property) {
                $output .= $this->processProperty($property);
            }
        }
        // Process methods
        $methods = $class->getMethods();
        if (!empty($methods)) {
            $output .= "## Methods\n\n";
            foreach ($methods as $method) {
                $output .= $this->processMethod($method);
            }
        }
        return $output;
    }
    /**
     * Process an interface and generate documentation
     */
    private function processInterface(InterfaceType $interface, string $fullInterfaceName): string
    {
        $headingLevel = $this->config['class_heading_level'];
        $output = \str_repeat('#', $headingLevel) . " Interface: $fullInterfaceName\n\n";
        // Add interface docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $interface->getComment()) {
            $docComment = $this->formatDocComment($interface->getComment());
            $output .= "$docComment\n\n";
        }
        // Process interface metadata
        if ($interface->getExtends()) {
            $extends = \array_map(static fn($e) => "`$e`", $interface->getExtends());
            $output .= "**Extends:** " . \implode(', ', $extends) . "\n\n";
        }
        // Process constants if enabled
        if ($this->config['include_constants'] && !empty($interface->getConstants())) {
            $output .= "## Constants\n\n";
            foreach ($interface->getConstants() as $constant) {
                $output .= $this->processConstant($constant);
            }
        }
        // Process methods
        $methods = $interface->getMethods();
        if (!empty($methods)) {
            $output .= "## Methods\n\n";
            foreach ($methods as $method) {
                $output .= $this->processMethod($method);
            }
        }
        return $output;
    }
    /**
     * Process a trait and generate documentation
     */
    private function processTrait(TraitType $trait, string $fullTraitName): string
    {
        $headingLevel = $this->config['class_heading_level'];
        $output = \str_repeat('#', $headingLevel) . " Trait: $fullTraitName\n\n";
        // Add trait docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $trait->getComment()) {
            $docComment = $this->formatDocComment($trait->getComment());
            $output .= "$docComment\n\n";
        }
        // Process properties
        $properties = $trait->getProperties();
        if (!empty($properties)) {
            $output .= "## Properties\n\n";
            foreach ($properties as $property) {
                $output .= $this->processProperty($property);
            }
        }
        // Process methods
        $methods = $trait->getMethods();
        if (!empty($methods)) {
            $output .= "## Methods\n\n";
            foreach ($methods as $method) {
                $output .= $this->processMethod($method);
            }
        }
        return $output;
    }
    /**
     * Process an enum and generate documentation (PHP 8.1+)
     */
    private function processEnum(EnumType $enum, string $fullEnumName): string
    {
        $headingLevel = $this->config['class_heading_level'];
        $output = \str_repeat('#', $headingLevel) . " Enum: $fullEnumName\n\n";
        // Add enum docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $enum->getComment()) {
            $docComment = $this->formatDocComment($enum->getComment());
            $output .= "$docComment\n\n";
        }
        // Process enum cases
        $cases = $enum->getCases();
        if (!empty($cases)) {
            $output .= "## Cases\n\n";
            foreach ($cases as $case) {
                $caseName = $case->getName();
                $output .= "- `$caseName`";
                if ($case->getValue() !== null) {
                    $output .= " = {$case->getValue()}";
                }
                if ($this->config['keep_doc_comments'] && $case->getComment()) {
                    $caseComment = \trim($this->formatDocComment($case->getComment()));
                    $output .= " - $caseComment";
                }
                $output .= "\n";
            }
            $output .= "\n";
        }
        // Process methods
        $methods = $enum->getMethods();
        if (!empty($methods)) {
            $output .= "## Methods\n\n";
            foreach ($methods as $method) {
                $output .= $this->processMethod($method);
            }
        }
        return $output;
    }
    /**
     * Process a property and generate documentation
     */
    private function processProperty(Property $property): string
    {
        // Skip properties based on visibility settings
        if ($property->isPrivate() && !$this->config['include_private_properties']) {
            return '';
        }
        if ($property->isProtected() && !$this->config['include_protected_properties']) {
            return '';
        }
        $propertyName = $property->getName();
        $output = "### \$$propertyName\n\n";
        // Add property visibility
        $visibility = $property->isPublic() ? 'public' : ($property->isProtected() ? 'protected' : 'private');
        $staticFlag = $property->isStatic() ? ' static' : '';
        // Add readonly flag if available (PHP 8.1+)
        $readonlyFlag = '';
        if (\method_exists($property, 'isReadonly') && $property->isReadonly()) {
            $readonlyFlag = ' readonly';
        }
        $output .= "`$visibility$staticFlag$readonlyFlag`\n\n";
        // Add property docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $property->getComment()) {
            $docComment = $this->formatDocComment($property->getComment());
            $output .= "$docComment\n\n";
        }
        // Add property type if available
        if ($property->getType()) {
            $type = $property->getType();
            $output .= "**Type:** `$type`\n\n";
        }
        // Add default value if available and enabled
        if ($this->config['include_property_defaults'] && $property->getValue() !== null) {
            $defaultValue = \var_export($property->getValue(), true);
            $output .= "**Default:** `$defaultValue`\n\n";
        }
        return $output;
    }
    /**
     * Process a method and generate documentation
     */
    private function processMethod(Method $method): string
    {
        // Skip methods based on visibility settings
        if ($method->isPrivate() && !$this->config['include_private_methods']) {
            return '';
        }
        if ($method->isProtected() && !$this->config['include_protected_methods']) {
            return '';
        }
        $methodName = $method->getName();
        $output = "### $methodName\n\n";
        // Add method visibility
        $visibility = $method->isPublic() ? 'public' : ($method->isProtected() ? 'protected' : 'private');
        $staticFlag = $method->isStatic() ? ' static' : '';
        $abstractFlag = $method->isAbstract() ? ' abstract' : '';
        $finalFlag = $method->isFinal() ? ' final' : '';
        $output .= "`$visibility$staticFlag$abstractFlag$finalFlag`\n\n";
        // Add method docblock if available and enabled
        if ($this->config['keep_doc_comments'] && $method->getComment()) {
            $docComment = $this->formatDocComment($method->getComment());
            $output .= "$docComment\n\n";
            // Extract route information if enabled
            if ($this->config['extract_routes']) {
                $routeInfo = $this->extractRouteInfo($method->getComment());
                if ($routeInfo) {
                    $output .= "**Route:** $routeInfo\n\n";
                }
            }
        }
        // Process method parameters
        $parameters = $method->getParameters();
        if (!empty($parameters)) {
            $output .= "**Parameters:**\n\n";
            foreach ($parameters as $param) {
                $paramName = $param->getName();
                $paramType = $param->getType() ?? 'mixed';
                $output .= "- `$paramType \$$paramName`";
                // Add default value if available
                if ($param->hasDefaultValue()) {
                    $defaultValue = \var_export($param->getDefaultValue(), true);
                    $output .= " = $defaultValue";
                }
                $output .= "\n";
            }
            $output .= "\n";
        }
        // Add return type if available
        if ($method->getReturnType()) {
            $returnType = $method->getReturnType();
            $output .= "**Returns:** `$returnType`\n\n";
        }
        // Include method body as a code block if enabled and not abstract
        if ($this->config['include_implementations'] && !$method->isAbstract()) {
            $methodCode = (string) $method;
            $output .= "```{$this->config['code_block_format']}\n$methodCode\n```\n\n";
        } else {
            // Just show the method signature
            $signature = $this->getMethodSignature($method);
            $output .= "```{$this->config['code_block_format']}\n$signature\n```\n\n";
        }
        return $output;
    }
    /**
     * Process a constant and generate documentation
     *
     * @param mixed $constant Constant object from nette/php-generator
     */
    private function processConstant($constant): string
    {
        $constName = $constant->getName();
        $output = "### $constName\n\n";
        // Add constant docblock if available and enabled
        if ($this->config['keep_doc_comments'] && \method_exists($constant, 'getComment') && $constant->getComment()) {
            $docComment = $this->formatDocComment($constant->getComment());
            $output .= "$docComment\n\n";
        }
        // Get constant value
        $constValue = \var_export($constant->getValue(), true);
        $output .= "**Value:** `$constValue`\n\n";
        return $output;
    }
    /**
     * Extract route information from method doc comment
     */
    private function extractRouteInfo(string $docComment): ?string
    {
        // Look for Symfony style route annotations
        if (\preg_match(
            '/@Route\s*\(\s*["\']([^"\']+)["\'](?:.*?methods\s*=\s*\{([^}]+)\})?/s',
            $docComment,
            $matches,
        )) {
            $path = $matches[1];
            $methods = isset($matches[2]) ? \trim(\str_replace(['"', "'"], '', $matches[2])) : 'GET';
            return "`$path` ($methods)";
        }
        // Look for Laravel style route annotations
        if (\preg_match('/@(Get|Post|Put|Delete|Patch)\s*\(\s*["\']([^"\']+)["\']/', $docComment, $matches)) {
            $method = \strtoupper($matches[1]);
            $path = $matches[2];
            return "`$path` ($method)";
        }
        return null;
    }
    /**
     * Get method signature without implementation
     */
    private function getMethodSignature(Method $method): string
    {
        $visibility = $method->isPublic() ? 'public ' : ($method->isProtected() ? 'protected ' : 'private ');
        $staticFlag = $method->isStatic() ? 'static ' : '';
        $abstractFlag = $method->isAbstract() ? 'abstract ' : '';
        $finalFlag = $method->isFinal() ? 'final ' : '';
        $methodName = $method->getName();
        $params = [];
        foreach ($method->getParameters() as $param) {
            $paramType = $param->getType() ? (string) $param->getType() . ' ' : '';
            $paramName = '$' . $param->getName();
            $paramStr = $paramType . $paramName;
            if ($param->hasDefaultValue()) {
                $defaultValue = \var_export($param->getDefaultValue(), true);
                $paramStr .= ' = ' . $defaultValue;
            }
            $params[] = $paramStr;
        }
        $paramsStr = \implode(', ', $params);
        $returnType = $method->getReturnType() ? ': ' . (string) $method->getReturnType() : '';
        return "{$visibility}{$staticFlag}{$abstractFlag}{$finalFlag}function {$methodName}({$paramsStr}){$returnType}";
    }
    /**
     * Format a doc comment by removing the comment markers
     */
    private function formatDocComment(string $docComment): string
    {
        // Remove comment start and end markers
        $docComment = \preg_replace(['#^\s*/\*+#', '#\*+/\s*$#'], '', $docComment);
        // Split into lines
        $lines = \explode("\n", (string) $docComment);
        $result = [];
        foreach ($lines as $line) {
            // Remove leading asterisks and whitespace
            $line = \preg_replace('#^\s*\*\s?#', '', $line);
            // Skip empty lines at the beginning
            if (empty($result) && \trim((string) $line) === '') {
                continue;
            }
            $result[] = $line;
        }
        // Join lines back together
        return \implode("\n", $result);
    }
}