diff --git a/src/Documentation/DocumentationMenuItem.php b/src/Documentation/DocumentationMenuItem.php index 3e28aea48..99575a0fc 100644 --- a/src/Documentation/DocumentationMenuItem.php +++ b/src/Documentation/DocumentationMenuItem.php @@ -10,7 +10,10 @@ final readonly class DocumentationMenuItem { /** - * @param non-empty-string|null $slug + * @param string $href The URL path for the documentation menu item. Must be a non-empty string starting with '/', 'http://', or 'https://' + * @param string $label The display text for the menu item. Must be a non-empty string + * @param non-empty-string|null $slug The unique identifier for the documentation file. If null, indicates a menu item without content + * @param bool $isNew Whether this menu item represents new documentation */ public function __construct( private string $href, @@ -18,6 +21,12 @@ public function __construct( private ?string $slug, private bool $isNew = false, ) { + Assert::notEmpty($href, 'Documentation href cannot be empty'); + Assert::true( + str_starts_with($href, '/') || str_starts_with($href, 'http://') || str_starts_with($href, 'https://'), + 'Documentation href must start with a forward slash, http://, or https://' + ); + Assert::notEmpty($label, 'Documentation label cannot be empty'); } public function getHref(): string @@ -43,12 +52,41 @@ public function getSlug(): ?string return $this->slug; } + /** + * Checks if the documentation file exists for this menu item. + * Throws an exception if the file does not exist when a slug is provided. + * + * @return bool True if the documentation file exists, false if slug is null + * @throws \Webmozart\Assert\InvalidArgumentException If the file does not exist when slug is provided + */ + public function hasDocumentation(): bool + { + if ($this->slug === null) { + return false; + } + + $documentationFilePath = $this->getDocumentationFilePath(); + Assert::fileExists($documentationFilePath, sprintf('Documentation file must exist at "%s"', $documentationFilePath)); + + return true; + } + public function getMarkdownContents(): string { Assert::notNull($this->slug); - $documentationFilePath = __DIR__ . '/../../resources/docs/' . $this->slug . '.md'; + $documentationFilePath = $this->getDocumentationFilePath(); Assert::fileExists($documentationFilePath); return FileSystem::read($documentationFilePath); } + + /** + * Gets the full path to the documentation file. + * + * @return string The absolute path to the documentation file + */ + private function getDocumentationFilePath(): string + { + return __DIR__ . '/../../resources/docs/' . $this->slug . '.md'; + } } diff --git a/src/ValueObject/AppliedRule.php b/src/ValueObject/AppliedRule.php index 5e341d098..7481d60ef 100644 --- a/src/ValueObject/AppliedRule.php +++ b/src/ValueObject/AppliedRule.php @@ -6,9 +6,20 @@ use App\Exception\ShouldNotHappenException; use Nette\Utils\Strings; +use Webmozart\Assert\Assert; final readonly class AppliedRule { + private const EXPECTED_CLASS_PARTS_COUNT_MIN = 5; + private const EXPECTED_CLASS_PARTS_COUNT_MAX = 6; + private const CATEGORY_INDEX = 1; + private const RECTOR_LITERAL_INDEX_5_PARTS = 2; + private const RECTOR_LITERAL_INDEX_6_PARTS = 3; + private const NODE_CLASS_INDEX_5_PARTS = 3; + private const NODE_CLASS_INDEX_6_PARTS = 4; + private const SHORT_CLASS_INDEX_5_PARTS = 4; + private const SHORT_CLASS_INDEX_6_PARTS = 5; + private string $shortRectorClass; public function __construct( @@ -40,16 +51,68 @@ public function getTestFixtureNamespace(): string public function getTestFixtureDirectoryPath(): string { $classParts = explode('\\', $this->rectorClass); + $partsCount = count($classParts); + + // Validate class structure - allow 5 or 6 parts (6 parts when version number is present) + Assert::true( + $partsCount >= self::EXPECTED_CLASS_PARTS_COUNT_MIN && $partsCount <= self::EXPECTED_CLASS_PARTS_COUNT_MAX, + sprintf( + 'Rector class "%s" must have %d or %d parts (e.g. "Rector\\Category\\Rector\\Node\\SomeRector" or "Rector\\Category\\Version\\Rector\\Node\\SomeRector")', + $this->rectorClass, + self::EXPECTED_CLASS_PARTS_COUNT_MIN, + self::EXPECTED_CLASS_PARTS_COUNT_MAX + ) + ); - $category = $classParts[1]; - $rulesDirectory = 'rules-tests/' . $category; + // Validate first part is "Rector" + Assert::same($classParts[0], 'Rector', sprintf('Rector class "%s" must start with "Rector"', $this->rectorClass)); + + // Determine if we have a version part (6 parts) or not (5 parts) + $hasVersion = $partsCount === self::EXPECTED_CLASS_PARTS_COUNT_MAX; + + // Validate "Rector" literal is at the correct position + $rectorLiteralIndex = $hasVersion ? self::RECTOR_LITERAL_INDEX_6_PARTS : self::RECTOR_LITERAL_INDEX_5_PARTS; + Assert::same( + $classParts[$rectorLiteralIndex], + 'Rector', + sprintf('Rector class "%s" must have "Rector" at index %d', $this->rectorClass, $rectorLiteralIndex) + ); - $nodeClass = $classParts[3]; - $shortClass = $classParts[4]; + // Get required parts with correct indices + // When there's a version (6 parts), use the version as the category; otherwise use the base category + $categoryIndex = $hasVersion ? 2 : self::CATEGORY_INDEX; + $category = $this->getClassPart($classParts, $categoryIndex, 'category'); + $nodeClassIndex = $hasVersion ? self::NODE_CLASS_INDEX_6_PARTS : self::NODE_CLASS_INDEX_5_PARTS; + $shortClassIndex = $hasVersion ? self::SHORT_CLASS_INDEX_6_PARTS : self::SHORT_CLASS_INDEX_5_PARTS; + + $nodeClass = $this->getClassPart($classParts, $nodeClassIndex, 'node class'); + $shortClass = $this->getClassPart($classParts, $shortClassIndex, 'short class'); + + $rulesDirectory = 'rules-tests/' . $category; return $rulesDirectory . '/Rector/' . $nodeClass . '/' . $shortClass . '/Fixture'; } + /** + * @param string[] $classParts + */ + private function getClassPart(array $classParts, int $index, string $partName): string + { + Assert::keyExists( + $classParts, + $index, + sprintf('Missing %s in rector class "%s"', $partName, $this->rectorClass) + ); + + $part = $classParts[$index]; + Assert::notEmpty( + $part, + sprintf('Empty %s in rector class "%s"', $partName, $this->rectorClass) + ); + + return $part; + } + /** * Mimics @see \App\RuleFilter\ValueObject\RuleMetadata::getSlug() */