Skip to content

Commit

Permalink
Generic AST extractor
Browse files Browse the repository at this point in the history
  • Loading branch information
Korbeil committed Dec 11, 2023
1 parent 60ecb7e commit e5a320d
Show file tree
Hide file tree
Showing 10 changed files with 264 additions and 36 deletions.
9 changes: 0 additions & 9 deletions src/Exception/CircularReferenceException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@

declare(strict_types=1);

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace AutoMapper\Exception;

/**
Expand Down
9 changes: 0 additions & 9 deletions src/Exception/CompileException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@

declare(strict_types=1);

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace AutoMapper\Exception;

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Exception/InvalidArgumentException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Exception;

/**
* @author Baptiste Leduc <baptiste.leduc@gmail.com>
*/
final class InvalidArgumentException extends \InvalidArgumentException
{
}
9 changes: 0 additions & 9 deletions src/Exception/InvalidMappingException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@

declare(strict_types=1);

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace AutoMapper\Exception;

/**
Expand Down
12 changes: 12 additions & 0 deletions src/Exception/LogicException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Exception;

/**
* @author Baptiste Leduc <baptiste.leduc@gmail.com>
*/
final class LogicException extends \LogicException
{
}
9 changes: 0 additions & 9 deletions src/Exception/NoMappingFoundException.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,6 @@

declare(strict_types=1);

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace AutoMapper\Exception;

/**
Expand Down
114 changes: 114 additions & 0 deletions src/Extractor/AstExtractor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Extractor;

use AutoMapper\Exception\InvalidArgumentException;
use AutoMapper\Exception\LogicException;
use AutoMapper\Exception\RuntimeException;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Identifier;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt;
use PhpParser\Parser;
use PhpParser\ParserFactory;

/**
* @author Nicolas Philippe <nikophil@gmail.com>
* @author Baptiste Leduc <baptiste.leduc@gmail.com>
*
* @internal
*/
final readonly class AstExtractor
{
private Parser $parser;

public function __construct(?Parser $parser = null)
{
$this->parser = $parser ?? (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
}

/**
* Extracts the code of the given method from a given class, and wraps it inside a closure, in order to inject it
* in the generated mappers.
*
* @param class-string $class
* @param Arg[] $inputParameters
*/
public function extract(string $class, string $method, array $inputParameters): Expr
{
$fileName = (new \ReflectionClass($class))->getFileName();
if (false === $fileName) {
throw new RuntimeException("You cannot extract code from \"{$class}\" class.");
}
$fileContents = file_get_contents($fileName);
if (false === $fileContents) {
throw new RuntimeException("File \"{$fileName}\" for \"{$class}\" couldn't be read.");
}

$statements = $this->parser->parse($fileContents);
if (null === $statements) {
throw new RuntimeException("Couldn't parse file \"{$fileName}\" for class \"{$class}\".");
}

$namespaceStatement = self::findUnique(Stmt\Namespace_::class, $statements, $fileName);
/** @var Stmt\Class_ $classStatement */
$classStatement = self::findUnique(Stmt\Class_::class, $namespaceStatement->stmts, $fileName);

$classMethod = $classStatement->getMethod($method) ?? throw new LogicException("Cannot find method \"{$method}()\" in class \"{$class}\".");

if (\count($inputParameters) !== \count($classMethod->getParams())) {
throw new InvalidArgumentException("Input parameters and method parameters in class \"{$class}\" do not match.");
}

foreach ($classMethod->getParams() as $key => $parameter) {
/** @var Expr\Variable $inputParameterValue */
$inputParameterValue = $inputParameters[$key]->value;

if ($parameter->var instanceof Expr\Variable && $inputParameterValue->name !== $parameter->var->name) {
$parameterName = \is_string($parameter->var->name) ? $parameter->var->name : 'N/A';
throw new InvalidArgumentException("Method parameter \"{$parameterName}\" does not match type \"{$inputParameters[$key]->getType()}\" from input parameter \"{$inputParameters[$key]->name}\" in \"{$class}::{$method}\" method.");
}
}

$closureParameters = [];
foreach ($classMethod->getParams() as $parameter) {
if ($parameter->var instanceof Expr\Variable && $parameter->type instanceof Identifier) {
$closureParameters[] = new Param(new Expr\Variable($parameter->var->name), type: $parameter->type->name);
}
}

return new Expr\FuncCall(
new Expr\Closure([
'stmts' => $classMethod->stmts,
'params' => $closureParameters,
'returnType' => $classMethod->returnType,
]),
$inputParameters,
);
}

/**
* @template T of Stmt
*
* @param class-string<T> $searchedStatementClass
* @param Stmt[] $statements
*
* @return T
*/
private static function findUnique(string $searchedStatementClass, array $statements, string $fileName): Stmt
{
$foundStatements = array_filter(
$statements,
static fn (Stmt $statement): bool => $statement instanceof $searchedStatementClass,
);

if (\count($foundStatements) > 1) {
throw new InvalidArgumentException("Multiple \"{$searchedStatementClass}\" found in file \"{$fileName}\".");
}

return array_values($foundStatements)[0] ?? throw new InvalidArgumentException("No \"{$searchedStatementClass}\" found in file \"{$fileName}\".");
}
}
89 changes: 89 additions & 0 deletions tests/Extractor/AstExtractorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Tests\Extractor;

use AutoMapper\Exception\InvalidArgumentException;
use AutoMapper\Exception\RuntimeException;
use AutoMapper\Extractor\AstExtractor;
use AutoMapper\Tests\Extractor\Fixtures\Foo;
use AutoMapper\Tests\Extractor\Fixtures\FooCustomMapper;
use PhpParser\Node\Arg;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Stmt\Expression;
use PhpParser\PrettyPrinter\Standard;
use PHPUnit\Framework\TestCase;

/**
* @author Baptiste Leduc <baptiste.leduc@gmail.com>
*/
class AstExtractorTest extends TestCase
{
public function testExtractSimpleMethod(): void
{
$extractor = new AstExtractor();
$extractedMethod = new Expression($extractor->extract(FooCustomMapper::class, 'transform', [new Arg(new Variable('object'))]));

$this->assertEquals(<<<PHP
(function (mixed \$object) : mixed {
if (\$object instanceof Foo) {
\$object->bar = 'Hello World!';
}
return \$object;
})(\$object);
PHP, $generatedCode = (new Standard())->prettyPrint([$extractedMethod]));

$codeToEval = <<<PHP
class Foo
{
public string \$bar;
public string \$baz;
}
\$object = new Foo();
\$object->bar = 'Hello';
{$generatedCode}
return \$object;
PHP;

/** @var Foo $object */
$object = eval($codeToEval);
$this->assertEquals('Hello World!', $object->bar);
}

public function testCannotExtractCode(): void
{
$coreClass = \Generator::class;

$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("You cannot extract code from \"{$coreClass}\" class.");

$extractor = new AstExtractor();
$extractor->extract($coreClass, 'rewind', [new Arg(new Variable('object'))]);
}

public function testInvalidInputParameters(): void
{
$class = FooCustomMapper::class;

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Input parameters and method parameters in class \"{$class}\" do not match.");

$extractor = new AstExtractor();
$extractor->extract($class, 'transform', [new Arg(new Variable('object')), new Arg(new Variable('context'))]);
}

public function testInvalidExtractedMethodParameters(): void
{
$class = FooCustomMapper::class;

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("Input parameters and method parameters in class \"{$class}\" do not match.");

$extractor = new AstExtractor();
$extractor->extract($class, 'switch', [new Arg(new Variable('object'))]);
}
}
11 changes: 11 additions & 0 deletions tests/Extractor/Fixtures/Foo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Tests\Extractor\Fixtures;

class Foo
{
public string $bar;
public string $baz;
}
26 changes: 26 additions & 0 deletions tests/Extractor/Fixtures/FooCustomMapper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace AutoMapper\Tests\Extractor\Fixtures;

class FooCustomMapper
{
public function transform(mixed $object): mixed
{
if ($object instanceof Foo) {
$object->bar = 'Hello World!';
}

return $object;
}

public function switch(mixed $object, array $context): mixed
{
if ($object instanceof Foo) {
$object->bar = 'Hello World!';
}

return $object;
}
}

0 comments on commit e5a320d

Please sign in to comment.