respect-validation/src-dev/Markdown/Linters/AssertionMessageLinter.php
Henrique Moody d2198dfd01
Replace isValid() calls with assert()
There's more value on showing how `assert()` displays the validation
messages than simply showing if `isValid()` returns `true` or `false`.

However, that increases the chances of having outdated documentation, so
I created a doc linter that updates the Markdown files with the
correct message.
2026-01-13 23:37:06 -07:00

361 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Dev\Markdown\Linters;
use ArrayObject;
use DateTimeImmutable;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\Expression;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
use Respect\Dev\Markdown\Content;
use Respect\Dev\Markdown\File;
use Respect\Dev\Markdown\Linter;
use Respect\Validation\Exceptions\ValidationException;
use Throwable;
use UnexpectedValueException;
use function array_keys;
use function array_merge;
use function array_pop;
use function array_slice;
use function array_values;
use function count;
use function date;
use function explode;
use function implode;
use function preg_match;
use function sprintf;
use function str_contains;
use function str_ends_with;
use function str_replace;
use function str_starts_with;
use function substr;
use function substr_count;
use function trim;
use const PHP_EOL;
final readonly class AssertionMessageLinter implements Linter
{
private const string FIXED_DATETIME = '2024-01-01 12:00:00+00:00';
public function lint(File $file): File
{
if (!str_contains($file->filename, '/validators/') || !str_ends_with($file->filename, '.md')) {
return $file;
}
$processedContent = $this->processContent($file->content);
if ($file->content->build() === $processedContent->build()) {
return $file;
}
return $file->withContent($processedContent);
}
private function processContent(Content $content): Content
{
$context = new ArrayObject();
$newContent = new Content();
$inCodeBlock = false;
$currentCodeBlock = [];
foreach ($content as $line) {
if (str_starts_with($line, '```php')) {
$inCodeBlock = true;
$currentCodeBlock = [$line];
continue;
}
if ($inCodeBlock && str_starts_with($line, '```')) {
$currentCodeBlock[] = $line;
$processedLines = $this->processCodeBlock($currentCodeBlock, $context);
foreach ($processedLines as $processedLine) {
$newContent->raw($processedLine);
}
$inCodeBlock = false;
$currentCodeBlock = [];
continue;
}
if ($inCodeBlock) {
$currentCodeBlock[] = $line;
continue;
}
$newContent->raw($line);
}
return $newContent;
}
/**
* @param string[] $lines
*
* @return string[]
*/
private function processCodeBlock(array $lines, ArrayObject $context): array
{
$openingLine = $lines[0];
$closingLine = $lines[count($lines) - 1];
$codeLines = array_slice($lines, 1, -1);
$code = implode("\n", $codeLines);
$processedCodeLines = $this->processCode($code, $context);
return [
$openingLine,
...$processedCodeLines,
$closingLine,
];
}
/** @return string[] */
private function processCode(string $code, ArrayObject $context): array
{
$cleanedCode = $this->stripOutputComments($code);
$parser = (new ParserFactory())->createForNewestSupportedVersion();
$printer = new PrettyPrinter();
$phpTagLength = 6;
$fullCode = '<?php ' . $cleanedCode;
try {
$statements = $parser->parse($fullCode);
} catch (Throwable $exception) {
throw new UnexpectedValueException(sprintf('Failed to parse code block: %s', $code), 0, $exception);
}
$processedLines = [];
$lastEndPos = $phpTagLength;
$lastWasAssertion = false;
foreach ($statements as $statement) {
$startPos = $statement->getStartFilePos();
$endPos = $statement->getEndFilePos();
if ($startPos > $lastEndPos && !$lastWasAssertion) {
$between = substr($fullCode, $lastEndPos, $startPos - $lastEndPos);
$newlineCount = substr_count($between, "\n");
for ($i = 1; $i < $newlineCount; $i++) {
$processedLines[] = '';
}
}
if ($startPos >= 0 && $endPos >= 0) {
$statementCode = substr($fullCode, $startPos, $endPos - $startPos + 1);
$restOfCode = substr($fullCode, $endPos + 1);
if (preg_match('/^(\h*\/\/[^\n]*)/u', $restOfCode, $matches)) {
$statementCode .= $matches[1];
}
} else {
$statementCode = $printer->prettyPrint([$statement]);
}
$isAssertion = $this->isValidationAssertion($statement);
if ($isAssertion) {
$processedLines[] = $statementCode;
$contextHasClass = $this->contextContainsClass($context);
$result = $this->executeStatement($statementCode, $context, $printer);
$processedLines = array_merge($processedLines, $result);
if ($contextHasClass) {
$context->exchangeArray([]);
}
} else {
$context->append($printer->prettyPrint([$statement]));
$processedLines[] = $statementCode;
}
$lastEndPos = $endPos + 1;
$lastWasAssertion = $isAssertion;
}
while (count($processedLines) > 0 && trim($processedLines[count($processedLines) - 1]) === '') {
array_pop($processedLines);
}
return $processedLines;
}
private function stripOutputComments(string $code): string
{
$lines = explode("\n", $code);
$filteredLines = [];
foreach ($lines as $line) {
$trimmed = trim($line);
if ($trimmed === '// Validation passes successfully') {
continue;
}
if (str_starts_with($trimmed, '// →')) {
continue;
}
$filteredLines[] = $line;
}
$result = [];
$lastWasEmpty = false;
foreach ($filteredLines as $line) {
$isEmpty = trim($line) === '';
if ($isEmpty && $lastWasEmpty) {
continue;
}
$result[] = $line;
$lastWasEmpty = $isEmpty;
}
while (count($result) > 0 && trim($result[count($result) - 1]) === '') {
array_pop($result);
}
return implode("\n", $result);
}
private function isValidationAssertion(Node $statement): bool
{
if (!$statement instanceof Expression) {
return false;
}
$expr = $statement->expr;
if (!$expr instanceof MethodCall) {
return false;
}
$methodName = $expr->name;
if (!$methodName instanceof Identifier) {
return false;
}
if ($methodName->name !== 'assert') {
return false;
}
return $this->originatesFromV($expr) || $this->originatesFromVariable($expr);
}
private function originatesFromV(Node $node): bool
{
if ($node instanceof StaticCall) {
$class = $node->class;
return $class instanceof Name && $class->toString() === 'v';
}
if ($node instanceof MethodCall) {
return $this->originatesFromV($node->var);
}
return false;
}
private function originatesFromVariable(Node $node): bool
{
if ($node instanceof Node\Expr\Variable) {
return true;
}
if ($node instanceof MethodCall) {
return $this->originatesFromVariable($node->var);
}
return false;
}
private function contextContainsClass(ArrayObject $context): bool
{
$fullContext = implode("\n", $context->getArrayCopy());
return (bool) preg_match('/^(final\s+)?class\s+[A-Za-z_][A-Za-z0-9_]*/m', $fullContext);
}
/** @return string[] */
private function executeStatement(string $statementCode, ArrayObject $context, PrettyPrinter $printer): array
{
$fullCode = implode("\n", [...$context->getArrayCopy(), $statementCode]);
$fullCode = $this->replacePlaceholders($fullCode);
$resultLines = [];
try {
eval($fullCode);
if (str_contains($statementCode, 'assert(')) {
$resultLines[] = '// Validation passes successfully';
}
} catch (ValidationException $e) {
$fullMessage = $e->getFullMessage();
if (str_contains($fullMessage, PHP_EOL)) {
foreach (explode(PHP_EOL, trim($fullMessage)) as $line) {
$resultLines[] = '// → ' . $this->formatExceptionMessage($line);
}
} else {
$resultLines[] = '// → ' . $this->formatExceptionMessage($e->getMessage());
}
} catch (Throwable $e) {
$resultLines[] = '// 𝙭 ' . $this->formatExceptionMessage($e->getMessage());
}
if (count($resultLines) > 0) {
$resultLines[] = '';
}
return $resultLines;
}
private function replacePlaceholders(string $code): string
{
return str_replace(array_keys($this->getReplacements()), array_values($this->getReplacements()), $code);
}
private function formatExceptionMessage(string $line): string
{
return str_replace(array_values($this->getReplacements()), array_keys($this->getReplacements()), $line);
}
/** @return array<string, string> */
private function getReplacements(): array
{
$dateTime = new DateTimeImmutable(self::FIXED_DATETIME);
return [
'/path/to/file.txt' => 'tests/fixtures/file.txt',
'/path/to/file' => 'tests/fixtures/file',
'/path/to/symbolic-link' => 'tests/fixtures/symbolic-link',
'/path/to/executable' => 'tests/fixtures/executable',
'/path/to/image.gif' => 'tests/fixtures/valid-image.gif',
'/path/to/image.png' => 'tests/fixtures/valid-image.png',
'/path/to/image.jpg' => 'tests/fixtures/valid-image.jpg',
'/path/to/dir' => 'tests/fixtures',
'__FILE__' => '"tests/fixtures/file"',
'__DIR__' => '"tests/fixtures"',
'new DateTime()' => 'new DateTime(\'' . self::FIXED_DATETIME . '\')',
'new DateTimeImmutable()' => 'new DateTimeImmutable(\'' . self::FIXED_DATETIME . '\')',
date('d/m/Y') => $dateTime->format('d/m/Y'),
$dateTime->format('d/m/Y') => date('d/m/Y'),
'new ClassWithToString()' => 'new \Respect\Validation\Test\Stubs\ToStringStub("string")',
];
}
}