respect-validation/src-dev/Markdown/Linters/AssertionMessageLinter.php
Alexandre Gomes Gaigalas d9cdc118b2 Introduce REUSE compliance
This commit introduces REUSE compliance by annotating all files
with SPDX information and placing the reused licences in the
LICENSES folder.

We additionally removed the docheader tool which is made obsolete
by this change.

The main LICENSE and copyright text of the project is now not under
my personal name anymore, and it belongs to "The Respect Project
Contributors" instead.

This change restores author names to several files, giving the
appropriate attribution for contributions.
2026-01-21 06:28:11 +00:00

362 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
/*
* SPDX-License-Identifier: MIT
* SPDX-FileCopyrightText: (c) Respect Project Contributors
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
*/
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")',
];
}
}