diff --git a/.gitignore b/.gitignore index 205e437..afc5f8e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /vendor .phpunit.result.cache /composer.lock +/coverage.clover diff --git a/composer.json b/composer.json index 9c61914..5f46165 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,14 @@ "ext-mbstring" : "*", "psr/http-message": ">=1.0", "fatcode/annotations": ">=1.0", - "zendframework/zend-diactoros": "^2.1" + "zendframework/zend-diactoros": ">=2.1", + "nikic/php-parser": ">=4.2" }, "require-dev": { "phpunit/phpunit": ">=8.0", - "mockery/mockery": ">=1.2" + "mockery/mockery": ">=1.2", + "vimeo/psalm": ">=3.2", + "squizlabs/php_codesniffer": ">=3.0" }, "autoload": { "psr-4": { @@ -38,10 +41,10 @@ "FatCode\\Tests\\OpenApi\\": "tests/" } }, - "repositories": [ - { - "type": "vcs", - "url": "https://github.com/fat-code/annotations" - } - ] + "scripts": { + "phpunit": "vendor/bin/phpunit --coverage-text", + "phpcs": "vendor/bin/phpcs --standard=PSR12 --warning-severity=0 src", + "phpcsf": "vendor/bin/phpcbf --standard=PSR12 --warning-severity=0 src", + "psalm": "vendor/bin/psalm --show-info=false" + } } diff --git a/examples/get_route_example.php b/examples/get_exampe/get_route_example.php similarity index 93% rename from examples/get_route_example.php rename to examples/get_exampe/get_route_example.php index e28be8c..44482d9 100644 --- a/examples/get_route_example.php +++ b/examples/get_exampe/get_route_example.php @@ -18,8 +18,8 @@ class Application { /** - * @Api\PathItem\Get( - * route="/hello/{name<\w>}", + * @Api\Operation\Get( + * route="/hello/{name:\w}", * responses=[ * @Api\Response(code=200, schema=@Api\Reference(TextPlain::class)) * ] diff --git a/examples/hello_world.php b/examples/hello_world.php deleted file mode 100644 index bf8e06c..0000000 --- a/examples/hello_world.php +++ /dev/null @@ -1,32 +0,0 @@ -getAttribute('name'))); + } + } + + /** + * @Api\Schema( + * title="Greeter schema", + * type="object" + * ) + */ + class Greeter + { + /** + * @Api\Property(type="string") + */ + public $name; + + public function __construct(string $name) + { + $this->name = $name; + } + } + + /** + * @Api\Operation\Get( + * description="Greet user", + * route="/welcome/{name}", + * responses=[ + * @Api\Response(code=200, schema=@Api\Reference(Greeter::class)) + * ] + * ) + */ + function sayHello(ServerRequestInterface $request) : ResponseInterface + { + return new Response(200, new Greeter($request->getAttribute('name'))); + } + # Run with `open-api run development` +} diff --git a/examples/multi_directory_project/index.php b/examples/multi_directory_project/index.php new file mode 100644 index 0000000..a6eebb2 --- /dev/null +++ b/examples/multi_directory_project/index.php @@ -0,0 +1,25 @@ + diff --git a/src/Analyzer/AnnotationProcessor.php b/src/Analyzer/AnnotationProcessor.php new file mode 100644 index 0000000..2ec5b0b --- /dev/null +++ b/src/Analyzer/AnnotationProcessor.php @@ -0,0 +1,19 @@ + $token) { + if (is_array($token) && $token[0] === T_NAMESPACE) { + $this->currentNamespace = $this->parseNamespace($stream); + } + + if (!is_array($token) || $token[0] !== T_CLASS) { + continue; + } + + $results[] = new Symbol( + $this->currentNamespace . '\\' . $this->parseClass($stream), + SymbolType::TYPE_CLASS() + ); + } + + return $results; + } + + private function parseClass(PhpStream $file) : string + { + $file->seekToken(T_STRING); + $className = $file->current()[1]; + $file->seekStartOfScope(); + $file->skipScope(); + + return $className; + } +} diff --git a/src/Analyzer/Parser/FunctionParser.php b/src/Analyzer/Parser/FunctionParser.php new file mode 100644 index 0000000..464f5b0 --- /dev/null +++ b/src/Analyzer/Parser/FunctionParser.php @@ -0,0 +1,60 @@ + $token) { + if (is_array($token) && $token[0] === T_NAMESPACE) { + $this->currentNamespace = $this->parseNamespace($stream); + } + + if (is_array($token) && $token[0] === T_CLASS) { + $stream->seekStartOfScope(); + $stream->skipScope(); + continue; + } + + if (!is_array($token) || $token[0] !== T_FUNCTION) { + continue; + } + + $results[] = new Symbol( + $this->currentNamespace . '\\' . $this->parseFunction($stream), + SymbolType::TYPE_FUNCTION() + ); + } + + return $results; + } + + private function parseFunction(PhpStream $file) : string + { + $file->seekToken(T_STRING); + $className = $file->current()[1]; + $file->seekStartOfScope(); + $file->skipScope(); + + return $className; + } +} diff --git a/src/Analyzer/Parser/NamespaceParser.php b/src/Analyzer/Parser/NamespaceParser.php new file mode 100644 index 0000000..aac78d1 --- /dev/null +++ b/src/Analyzer/Parser/NamespaceParser.php @@ -0,0 +1,30 @@ +valid()) { + $stream->next(); + $token = $stream->current(); + + if (is_array($token) && ($token[0] === T_STRING || $token[0] === T_NS_SEPARATOR)) { + $namespace .= $token[1]; + } + + if ($token === ';' || $token === '{') { // End of namespace + return $namespace; + } + } + + throw ProjectAnalyzerException::forInvalidNamespace(); + } +} diff --git a/src/Analyzer/Parser/StreamAnalyzer.php b/src/Analyzer/Parser/StreamAnalyzer.php new file mode 100644 index 0000000..81dce78 --- /dev/null +++ b/src/Analyzer/Parser/StreamAnalyzer.php @@ -0,0 +1,14 @@ +name = $name; + $this->type = $type; + } + + public function getName() : string + { + return $this->name; + } + + public function getType() : SymbolType + { + return $this->type; + } + + public function __toString() : string + { + return "{$this->type}:{$this->name}"; + } +} diff --git a/src/Analyzer/Parser/SymbolType.php b/src/Analyzer/Parser/SymbolType.php new file mode 100644 index 0000000..82e5751 --- /dev/null +++ b/src/Analyzer/Parser/SymbolType.php @@ -0,0 +1,26 @@ +getValue(); + } +} diff --git a/src/Analyzer/PhpStream.php b/src/Analyzer/PhpStream.php new file mode 100644 index 0000000..c4069e7 --- /dev/null +++ b/src/Analyzer/PhpStream.php @@ -0,0 +1,178 @@ +stream = $stream; + $this->tokens = token_get_all($stream); + $this->length = count($this->tokens); + $this->context = $context; + } + + public static function fromFile(string $fileName) : PhpStream + { + self::validateFile($fileName); + return new self(file_get_contents($fileName), $fileName); + } + + public function getContext() : string + { + return $this->context; + } + + public function current() + { + return $this->tokens[$this->cursor]; + } + + public function prev() : void + { + $this->cursor--; + } + + public function next() : void + { + $this->cursor++; + } + + public function key() + { + return $this->cursor; + } + + public function valid() : bool + { + return $this->cursor >= 0 && $this->cursor < $this->length; + } + + public function rewind() : void + { + $this->cursor = 0; + } + + public function getCursor() : int + { + return $this->cursor; + } + + public function getTokens() : array + { + return $this->tokens; + } + + public function getTokenAt(int $cursor) + { + return $this->tokens[$cursor]; + } + + public function seekToken(int $token) : bool + { + while ($this->valid()) { + $current = $this->current(); + + if (!is_array($current)) { + continue; + } + + if ($current[0] === $token) { + return true; + } + $this->next(); + } + + return false; + } + + public function seekStartOfScope() : bool + { + while ($this->valid()) { + $current = $this->current(); + + if (is_array($current)) { + $this->next(); + continue; + } + + if ($current === '{') { + return true; + } + $this->next(); + } + + return false; + } + + public function skipScope() : bool + { + if ($this->current() === '{') { + $this->next(); + } + $depth = 1; + while ($this->valid()) { + $current = $this->current(); + + if (is_array($current)) { + $this->next(); + continue; + } + + if ($current === '{') { + $depth++; + $this->next(); + continue; + } + + if ($current === '}') { + $depth--; + if ($depth === 0) { + return true; + } + } + $this->next(); + } + + return false; + } + + public function countTokens() : int + { + return $this->length; + } + + public function __toString() : string + { + return $this->stream; + } + + private static function validateFile(string $fileName) : void + { + if (!is_file($fileName) || !is_readable($fileName)) { + throw FileException::notReadable($fileName); + } + + try { + require_once $fileName; + } catch (Throwable $throwable) { + throw FileException::invalidFile($fileName); + } + } +} diff --git a/src/Analyzer/Project.php b/src/Analyzer/Project.php new file mode 100644 index 0000000..7f516dd --- /dev/null +++ b/src/Analyzer/Project.php @@ -0,0 +1,52 @@ +symbols[] = $symbol; + } + } + + public function getSymbols() : array + { + return $this->symbols; + } + + public function getIterator() : Iterator + { + return new ArrayIterator($this->symbols); + } + + public function listDeclaredFunctions() : iterable + { + foreach ($this->symbols as $symbol) { + if ($symbol->getType() === SymbolType::TYPE_FUNCTION()) { + yield $symbol; + } + } + } + + public function listDeclaredClasses() : iterable + { + foreach ($this->symbols as $symbol) { + if ($symbol->getType() === SymbolType::TYPE_CLASS()) { + yield $symbol; + } + } + } +} diff --git a/src/Analyzer/ProjectFactory.php b/src/Analyzer/ProjectFactory.php new file mode 100644 index 0000000..d281bd9 --- /dev/null +++ b/src/Analyzer/ProjectFactory.php @@ -0,0 +1,47 @@ +analyzers = [ + 'classes' => new ClassParser(), + 'functions' => new FunctionParser(), + ]; + } + + public function create(string $dirname) : Project + { + if (!is_dir($dirname)) { + throw ProjectAnalyzerException::forInvalidDirectory($dirname); + } + $directory = new RecursiveDirectoryIterator($dirname); + $allFiles = new RecursiveIteratorIterator($directory); + $phpFiles = new RegexIterator($allFiles, '/.*\.php$/i'); + $project = new Project(); + /** @var SplFileInfo $file */ + foreach ($phpFiles as $file) { + foreach ($this->analyzers as $analyzer) { + $project->addSymbol(...$analyzer->analyze(PhpStream::fromFile($file->getRealPath()))); + } + } + + return $project; + } +} diff --git a/src/Annotation/Application.php b/src/Annotation/Application.php index 48b1481..907cbe0 100644 --- a/src/Annotation/Application.php +++ b/src/Annotation/Application.php @@ -74,4 +74,3 @@ final class Application */ public $security; } - diff --git a/src/Annotation/PathItem/CookieParameter.php b/src/Annotation/Operation/CookieParameter.php similarity index 83% rename from src/Annotation/PathItem/CookieParameter.php rename to src/Annotation/Operation/CookieParameter.php index 6dacc6f..9f27667 100644 --- a/src/Annotation/PathItem/CookieParameter.php +++ b/src/Annotation/Operation/CookieParameter.php @@ -1,6 +1,6 @@ analyze(PhpStream::fromFile(__FILE__)); + + self::assertCount(1, $info); + self::assertSame(__CLASS__, $info[0]->getName()); + } +} diff --git a/tests/Analyzer/Parser/FunctionParserTest.php b/tests/Analyzer/Parser/FunctionParserTest.php new file mode 100644 index 0000000..490acf6 --- /dev/null +++ b/tests/Analyzer/Parser/FunctionParserTest.php @@ -0,0 +1,33 @@ +analyze(PhpStream::fromFile(__FILE__)); + + self::assertCount(2, $info); + self::assertSame(__NAMESPACE__ . '\test_a', $info[0]->getName()); + self::assertSame(__NAMESPACE__ . '\test_b', $info[1]->getName()); + } +} + +function test_a() +{ +} + +function test_b() +{ +} diff --git a/tests/Analyzer/ProjectAnalyzerTests.php b/tests/Analyzer/ProjectAnalyzerTests.php new file mode 100644 index 0000000..270070d --- /dev/null +++ b/tests/Analyzer/ProjectAnalyzerTests.php @@ -0,0 +1,19 @@ +create(); + + self::assertInstanceOf(Project::class, $project); + self::assertCount(3, $project->getSymbols()); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..a602eda --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,6 @@ +