From f06650f829d684b6344377c4243a8ac3761d7d41 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 26 Oct 2026 08:08:45 +0000 Subject: [PATCH 1/2] Keep paragraphs apart in ApiGen descriptions phpdoc-parser puts each paragraph of a PHPDoc into its own text node. ApiGen dropped the empty nodes between them and joined the rest with a space, so every description rendered as one paragraph. Lists, tables and code fences that did not start the description were not recognized, and short descriptions in namespace listings showed the whole text. Amp-Thread-ID: https://ampcode.com/threads/T-01a176c6-528a-7430-b296-e66930d153b4 --- apigen/apigen.neon | 3 +++ apigen/src/MarkdownConverter.php | 11 +++++++++- apigen/src/PhpDocResolver.php | 35 ++++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 apigen/src/PhpDocResolver.php diff --git a/apigen/apigen.neon b/apigen/apigen.neon index 351f94357ed..908f11227b9 100644 --- a/apigen/apigen.neon +++ b/apigen/apigen.neon @@ -9,5 +9,8 @@ services: renderer.filter: factory: PHPStan\ApiGen\RendererFilter + analyze.phpDocResolver: + factory: PHPStan\ApiGen\PhpDocResolver + commonMark: factory: PHPStan\ApiGen\MarkdownConverter diff --git a/apigen/src/MarkdownConverter.php b/apigen/src/MarkdownConverter.php index 8d8c8c05445..4e616103255 100644 --- a/apigen/src/MarkdownConverter.php +++ b/apigen/src/MarkdownConverter.php @@ -7,8 +7,10 @@ use League\CommonMark\Extension\CommonMark\Node\Inline\HtmlInline; use League\CommonMark\Extension\GithubFlavoredMarkdownExtension; use League\CommonMark\MarkdownConverter as BaseMarkdownConverter; +use League\CommonMark\Output\RenderedContentInterface; +use function str_replace; -/** GitHub-flavored Markdown converter that escapes generic types written in PHPDoc descriptions. */ +/** GitHub-flavored Markdown converter for PHPDoc descriptions that escapes generic types and keeps paragraphs apart. */ final class MarkdownConverter extends BaseMarkdownConverter { @@ -22,4 +24,11 @@ public function __construct() parent::__construct($environment); } + public function convert(string $input): RenderedContentInterface + { + // ApiGen joins description nodes with a space, which would indent the paragraph + // after each separator and break code fences that start it. + return parent::convert(str_replace(' ' . PhpDocResolver::PARAGRAPH_SEPARATOR . ' ', PhpDocResolver::PARAGRAPH_SEPARATOR, $input)); + } + } diff --git a/apigen/src/PhpDocResolver.php b/apigen/src/PhpDocResolver.php new file mode 100644 index 00000000000..58c4b48709b --- /dev/null +++ b/apigen/src/PhpDocResolver.php @@ -0,0 +1,35 @@ +children as $child) { + if ($child instanceof PhpDocTextNode && $child->text === '') { + $children[] = new PhpDocTextNode(self::PARAGRAPH_SEPARATOR); + continue; + } + + $children = array_merge($children, parent::resolvePhpDoc(new PhpDocNode([$child]))->children); + } + + return new PhpDocNode($children); + } + +} From cd6acd65f5c6170cd27b681e52f91f408d383a76 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Mon, 26 Oct 2026 08:08:45 +0000 Subject: [PATCH 2/2] Use code fences for code samples in PHPDocs phpdoc-parser trims the first line of a paragraph, so indented code samples lost the indentation that made them code blocks. Amp-Thread-ID: https://ampcode.com/threads/T-01a176c6-528a-7430-b296-e66930d153b4 --- .../ContainerExtensionsExtension.php | 4 +++- src/TrinaryLogic.php | 20 ++++++++++--------- src/Type/Accessory/AccessoryType.php | 8 ++++++-- src/Type/GeneralizePrecision.php | 4 +++- src/Type/Type.php | 17 ++++++++-------- src/Type/TypeTraverser.php | 2 ++ src/Type/VerbosityLevel.php | 8 +++++--- 7 files changed, 39 insertions(+), 24 deletions(-) diff --git a/src/DependencyInjection/ContainerExtensionsExtension.php b/src/DependencyInjection/ContainerExtensionsExtension.php index 383c65f2a04..08e07609449 100644 --- a/src/DependencyInjection/ContainerExtensionsExtension.php +++ b/src/DependencyInjection/ContainerExtensionsExtension.php @@ -18,7 +18,9 @@ * * Extending ExtensionsExtension is required, not cosmetic. Compiler::processExtensions() starts with * - * $first = $this->getExtensions(ParametersExtension::class) + $this->getExtensions(ExtensionsExtension::class); + * ```php + * $first = $this->getExtensions(ParametersExtension::class) + $this->getExtensions(ExtensionsExtension::class); + * ``` * * and only extensions matching those types by instanceof have their loadConfiguration() called before * the compiler snapshots its extension list. Everything registered after that snapshot is rejected with diff --git a/src/TrinaryLogic.php b/src/TrinaryLogic.php index 1707443fdaf..15a46e60815 100644 --- a/src/TrinaryLogic.php +++ b/src/TrinaryLogic.php @@ -20,15 +20,17 @@ * depend on runtime values that can't be known statically. Extension developers * encounter TrinaryLogic extensively when querying type properties: * - * if ($type->isString()->yes()) { - * // Definitely a string - * } - * if ($type->isString()->maybe()) { - * // Could be a string (e.g. mixed) - * } - * if ($type->isString()->no()) { - * // Definitely not a string - * } + * ```php + * if ($type->isString()->yes()) { + * // Definitely a string + * } + * if ($type->isString()->maybe()) { + * // Could be a string (e.g. mixed) + * } + * if ($type->isString()->no()) { + * // Definitely not a string + * } + * ``` * * TrinaryLogic supports logical operations (and, or, negate) that propagate * uncertainty correctly. It is used as a flyweight — instances are cached and diff --git a/src/Type/Accessory/AccessoryType.php b/src/Type/Accessory/AccessoryType.php index e067a4bf012..3dbb81b36b7 100644 --- a/src/Type/Accessory/AccessoryType.php +++ b/src/Type/Accessory/AccessoryType.php @@ -15,11 +15,15 @@ * * For example, `non-empty-string` is represented as: * - * IntersectionType([StringType, AccessoryNonEmptyStringType]) + * ``` + * IntersectionType([StringType, AccessoryNonEmptyStringType]) + * ``` * * And `non-empty-list` is represented as: * - * IntersectionType([ArrayType(int, int), NonEmptyArrayType, AccessoryArrayListType]) + * ``` + * IntersectionType([ArrayType(int, int), NonEmptyArrayType, AccessoryArrayListType]) + * ``` * * Each AccessoryType implementation has a corresponding query method on the `Type` interface * that returns `TrinaryLogic`. This lets any code query the refinement without knowing diff --git a/src/Type/GeneralizePrecision.php b/src/Type/GeneralizePrecision.php index 5947f889ba3..5a9b45e9f9e 100644 --- a/src/Type/GeneralizePrecision.php +++ b/src/Type/GeneralizePrecision.php @@ -22,7 +22,9 @@ * * Used as a parameter to Type::generalize(): * - * $type->generalize(GeneralizePrecision::lessSpecific()) + * ```php + * $type->generalize(GeneralizePrecision::lessSpecific()) + * ``` */ #[ReferencedByTurboExtension(key: 'generalizePrecision')] final class GeneralizePrecision diff --git a/src/Type/Type.php b/src/Type/Type.php index ba36621e95b..d46008babbd 100644 --- a/src/Type/Type.php +++ b/src/Type/Type.php @@ -36,11 +36,13 @@ * will miss union types, intersection types with accessory types, and other composite forms. * Always use the `is*()` methods or `isSuperTypeOf()` instead: * - * // Wrong: - * if ($type instanceof StringType) { ... } + * ```php + * // Wrong: + * if ($type instanceof StringType) { ... } * - * // Correct: - * if ($type->isString()->yes()) { ... } + * // Correct: + * if ($type->isString()->yes()) { ... } + * ``` * * @api * @api-do-not-implement @@ -563,14 +565,13 @@ public function getGreaterOrEqualType(PhpVersion $phpVersion): Type; /** * Returns actual template type for a given object. * - * Example: - * - * @-template T - * class Foo {} + * For example, with a class `Foo` that declares `@template T`: * + * ```php * // $fooType is Foo * $t = $fooType->getTemplateType(Foo::class, 'T'); * $t->isInteger(); // yes + * ``` * * Returns ErrorType in case of a missing type. * diff --git a/src/Type/TypeTraverser.php b/src/Type/TypeTraverser.php index 49017dc9da2..836431ad8c2 100644 --- a/src/Type/TypeTraverser.php +++ b/src/Type/TypeTraverser.php @@ -30,6 +30,7 @@ final class TypeTraverser * The following example converts constant strings to objects, while * preserving unions and intersections: * + * ```php * TypeTraverser::map($type, function (Type $type, callable $traverse): Type { * if ($type instanceof UnionType || $type instanceof IntersectionType) { * // Traverse inner types @@ -42,6 +43,7 @@ final class TypeTraverser * // Replaces the current type, and don't traverse * return new MixedType(); * }); + * ``` * * @api * @param TypeTraverserCallable|callable(Type $type, callable(Type): Type $traverse): Type $cb diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index fb760a80bfb..662da1cbda1 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -32,9 +32,11 @@ * * Used as a parameter to Type::describe() to control output detail: * - * $type->describe(VerbosityLevel::typeOnly()) // "string" - * $type->describe(VerbosityLevel::value()) // "'hello'" - * $type->describe(VerbosityLevel::precise()) // "non-empty-lowercase-string" + * ```php + * $type->describe(VerbosityLevel::typeOnly()) // "string" + * $type->describe(VerbosityLevel::value()) // "'hello'" + * $type->describe(VerbosityLevel::precise()) // "non-empty-lowercase-string" + * ``` * * The getRecommendedLevelByType() factory method automatically chooses the right level * for error messages based on what types are involved — it picks the minimum verbosity