1
0

Ubah dari Swagger ke Dedoc lalu buat API Ticket dan TicketType

This commit is contained in:
Wian Drs
2026-06-20 00:12:19 +07:00
parent 2f805a233d
commit 2dc461ae89
1283 changed files with 36405 additions and 89470 deletions
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble;
class AbstractOpenApiVisitor implements OpenApiVisitor
{
public function enter(mixed $object, array $path = []) {}
public function leave(mixed $object, array $path = []) {}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class BodyParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('body', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class CookieParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('cookie', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Adds metadata to endpoints.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Endpoint
{
public function __construct(
/**
* Assigns an OperationID to a controller method.
*/
public readonly ?string $operationId = null,
/**
* Sets the title (summary) of the endpoint.
*/
public readonly ?string $title = null,
/**
* Sets the description of the endpoint.
*/
public readonly ?string $description = null,
/**
* Allows to override the method of the endpoint in the documentation. Useful when you want to document the
* `PUT|PATCH` endpoint as `PATCH`. The method provided here MUST be the actual method the API will reply to.
*/
public readonly ?string $method = null,
) {}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Dedoc\Scramble\Support\Generator\Example as OpenApiExample;
use Dedoc\Scramble\Support\Generator\MissingValue as OpenApiMissingValue;
class Example
{
public function __construct(
public mixed $value = new MissingValue,
public ?string $summary = null,
public ?string $description = null,
public ?string $externalValue = null,
) {}
public static function toOpenApiExample(Example $example): OpenApiExample
{
return new OpenApiExample(
value: $example->value instanceof MissingValue ? new OpenApiMissingValue : $example->value,
summary: $example->summary,
description: $example->description,
externalValue: $example->externalValue,
);
}
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Excludes all routes of a controller from the API documentation. Applies to controller's methods.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class ExcludeAllRoutesFromDocs {}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Excludes a route from API documentation. Applies to controller's methods.
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class ExcludeRouteFromDocs {}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Groups endpoints.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class Group
{
public function __construct(
public readonly ?string $name = null,
public readonly ?string $description = null,
/**
* Determines the ordering of the groups. Groups with the same weight, are sorted
* by the name (with `SORT_LOCALE_STRING` sorting flag).
*/
public readonly int $weight = PHP_INT_MAX,
) {}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Generator\Header as OpenApiHeader;
use Dedoc\Scramble\Support\Generator\MissingValue as OpenApiMissingValue;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\MixedType;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Generator\Types\Type as OpenApiType;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\PhpDoc;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Header
{
/**
* @param scalar|array<mixed>|object|MissingValue $example
*/
public function __construct(
public readonly string $name,
public readonly ?string $description = null,
public readonly ?string $type = null,
public readonly ?string $format = null,
public readonly ?bool $required = null,
/** @var array<mixed>|scalar|null|MissingValue */
public readonly mixed $default = new MissingValue,
public readonly mixed $example = new MissingValue,
/** @var array<string, Example> $examples */
public readonly array $examples = [],
public readonly ?bool $deprecated = null,
public readonly ?bool $explode = null,
public readonly int|string $status = 200,
) {}
public static function toOpenApiHeader(Header $header, TypeTransformer $openApiTransformer): OpenApiHeader
{
$type = self::getType($header, $openApiTransformer);
$default = $header->default instanceof MissingValue ? new OpenApiMissingValue : $header->default;
$format = $header->format ?: '';
if ($type instanceof MixedType && ($format || ! $default instanceof OpenApiMissingValue)) {
$type = new StringType;
}
return new OpenApiHeader(
description: $header->description,
required: $header->required,
deprecated: $header->deprecated,
explode: $header->explode,
schema: Schema::fromType(
$type->default($default)->format($format)
),
example: $header->example instanceof MissingValue ? new OpenApiMissingValue : $header->example,
examples: array_map(fn ($e) => Example::toOpenApiExample($e), $header->examples),
);
}
public static function getType(Header $header, TypeTransformer $openApiTransformer): OpenApiType
{
return $header->type ? $openApiTransformer->transform(
PhpDocTypeHelper::toType(
PhpDoc::parse("/** @return $header->type */")->getReturnTagValues()[0]->type ?? new IdentifierTypeNode('mixed')
)
) : new MixedType;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class HeaderParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('header', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Marks a class property as hidden from OpenAPI object schema.
*/
#[Attribute(Attribute::TARGET_PROPERTY)]
class Hidden {}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class IgnoreParam
{
/**
* @param 'query'|'path'|'header'|'cookie'|'body'|null $in
*/
public function __construct(
public readonly string $name,
public readonly ?string $in = null,
) {}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
namespace Dedoc\Scramble\Attributes;
class MissingValue {}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class Parameter
{
public readonly bool $required;
/**
* @param 'query'|'path'|'header'|'cookie'|'body' $in
* @param scalar|array|object|MissingValue $example
* @param array<string, Example> $examples The key is a distinct name and the value is an example object.
*/
public function __construct(
public readonly string $in,
public readonly string $name,
public readonly ?string $description = null,
?bool $required = null,
public bool $deprecated = false,
public ?string $type = null,
public ?string $format = null,
public bool $infer = true,
public mixed $default = new MissingValue,
public mixed $example = new MissingValue,
public array $examples = [],
) {
$this->required = $required !== null ? $required : $this->in === 'path';
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class PathParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('path', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_ALL)]
class QueryParameter extends Parameter
{
public function __construct(
string $name,
?string $description = null,
?bool $required = null,
$deprecated = false,
?string $type = null,
?string $format = null,
bool $infer = true,
mixed $default = new MissingValue,
mixed $example = new MissingValue,
array $examples = [],
) {
parent::__construct('query', $name, $description, $required, $deprecated, $type, $format, $infer, $default, $example, $examples);
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Generator\Reference;
use Dedoc\Scramble\Support\Generator\Response as OpenApiResponse;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\PhpDoc;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use function DeepCopy\deep_copy;
/**
* @phpstan-type BaseExample array<mixed>|scalar|null
*/
#[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_METHOD | Attribute::TARGET_FUNCTION)]
class Response
{
public function __construct(
public readonly int|string $status = 200,
public readonly ?string $description = null,
public readonly string $mediaType = 'application/json',
public readonly ?string $type = null,
public readonly ?string $format = null,
/** @var BaseExample|BaseExample[] $examples */
public readonly mixed $examples = [],
) {}
public static function toOpenApiResponse(Response $responseAttribute, ?OpenApiResponse $originalResponse, TypeTransformer $openApiTransformer, ?FileNameResolver $nameResolver): OpenApiResponse
{
$response = $originalResponse ? deep_copy($originalResponse) : OpenApiResponse::make($responseAttribute->status);
$response = self::applyResponseMediaType($responseAttribute, $response, $openApiTransformer, $nameResolver);
$response->setDescription(self::getDescription($responseAttribute, $response));
return $response;
}
private static function applyResponseMediaType(Response $responseAttribute, OpenApiResponse $response, TypeTransformer $openApiTransformer, ?FileNameResolver $nameResolver): OpenApiResponse
{
if (! $responseAttribute->type) {
return $response
->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $response),
);
}
$responseFromType = $openApiTransformer->toResponse(
PhpDocTypeHelper::toType(
PhpDoc::parse("/** @return $responseAttribute->type */", $nameResolver)->getReturnTagValues()[0]->type ?? new IdentifierTypeNode('mixed')
)
);
if ($responseFromType instanceof Reference) {
$responseFromType = deep_copy($responseFromType->resolve());
}
/** @var OpenApiResponse|null $responseFromType */
if (! $responseFromType) {
return $response->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $response),
);
}
return $response
->setDescription($responseFromType->description ?: self::getDescription($responseAttribute, $response))
->setContent(
$responseAttribute->mediaType,
self::getMediaType($responseAttribute, $responseFromType),
);
}
private static function getDescription(Response $responseAttribute, OpenApiResponse $response): string
{
if ($responseAttribute->description === null) {
return $response->description;
}
return Str::replace('$0', $response->description, $responseAttribute->description);
}
private static function getMediaType(Response $responseAttribute, OpenApiResponse $response): Schema|Reference
{
return self::getSchema($responseAttribute, $response->content[$responseAttribute->mediaType] ?? null);
}
private static function getSchema(Response $responseAttribute, Schema|Reference|null $schema): Schema|Reference
{
if (! $responseAttribute->format && ! $responseAttribute->examples) {
return $schema ?: Schema::fromType(new StringType);
}
$schemaType = $schema
? clone ($schema instanceof Reference ? $schema->resolve()->type : $schema->type)
: new StringType;
/** @var Type $schemaType */
if ($responseAttribute->format) {
$schemaType->format($responseAttribute->format);
}
if ($responseAttribute->examples) {
$schemaType->examples($responseAttribute->examples); // @phpstan-ignore argument.type
}
return Schema::fromType($schemaType);
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Attributes;
use Attribute;
/**
* Allows naming class based schemas for different contexts.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class SchemaName
{
public function __construct(
public readonly string $name,
/**
* Some classes can be used both as input and output schemas. So this property is used to
* explicitly name the schema when is in input context.
*/
public readonly ?string $input = null,
) {}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Dedoc\Scramble;
class CacheableGenerator
{
public function __construct(
private Generator $generator,
) {}
public function __invoke(?GeneratorConfig $config = null): array
{
$config ??= Scramble::getGeneratorConfig(Scramble::DEFAULT_API);
$store = config('scramble.cache.store');
$keyBase = config('scramble.cache.key');
if (! $store || ! $keyBase) {
return ($this->generator)($config);
}
$key = $keyBase.':'.$this->resolveApi($config);
if ($cached = cache()->store($store)->get($key)) {
return $cached;
}
return ($this->generator)($config);
}
private function resolveApi(GeneratorConfig $config): string
{
foreach (Scramble::getConfigurationsInstance()->all() as $api => $generatorConfig) {
if ($generatorConfig === $config) {
return $api;
}
}
return Scramble::DEFAULT_API;
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use InvalidArgumentException;
class ApiPath
{
/**
* @param list<string> $includes
* @param list<string> $excludes
*/
private function __construct(
private readonly array $includes,
private readonly array $excludes,
) {}
/**
* @param string|array{include?: string|string[], exclude?: string|string[]}|null $config
*/
public static function from(mixed $config, string $default = 'api'): self
{
if ($config === null || is_string($config)) {
return new self(self::normalizePrefixes($config ?? $default), []);
}
if (is_array($config) && (array_key_exists('include', $config) || array_key_exists('exclude', $config))) {
return new self(
self::normalizePrefixes($config['include'] ?? $default),
self::normalizePrefixes($config['exclude'] ?? []),
);
}
throw new InvalidArgumentException(
'Invalid scramble.api_path config. Expected a string or an array with `include` and/or `exclude` keys.'
);
}
/**
* @return list<string>
*/
private static function normalizePrefixes(mixed $prefixes): array
{
if ($prefixes === '' || $prefixes === null) {
return [];
}
return array_values(array_filter(Arr::wrap($prefixes), fn ($prefix) => is_string($prefix) && $prefix !== ''));
}
public function matches(string $uri): bool
{
if ($this->includes !== [] && ! $this->matchesAny($uri, $this->includes)) {
return false;
}
return ! $this->matchesAny($uri, $this->excludes);
}
public function stripPrefix(string $uri): string
{
if (! $this->usesSingleBase()) {
return $uri;
}
return (string) Str::of($uri)
->replaceStart($this->includes[0], '')
->trim('/');
}
public function serverPath(): string
{
return $this->usesSingleBase() ? $this->includes[0] : '';
}
private function usesSingleBase(): bool
{
return count($this->includes) === 1 && ! str_contains($this->includes[0], '*');
}
/**
* @param list<string> $patterns
*/
private function matchesAny(string $uri, array $patterns): bool
{
foreach ($patterns as $pattern) {
if (str_contains($pattern, '*') ? Str::is($pattern, $uri) : ($uri === $pattern || Str::startsWith($uri, $pattern.'/'))) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,56 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Illuminate\Support\Arr;
class DocumentTransformers
{
protected array $transformers = [];
protected array $appends = [];
protected array $prepends = [];
public function append(array|callable|string $transformers)
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($transformers)
);
return $this;
}
public function prepend(array|callable|string $transformers)
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($transformers)
);
return $this;
}
public function use(array $transformers)
{
$this->transformers = $transformers;
return $this;
}
/**
* @return (callable|class-string<DocumentTransformer>)[]
*/
public function all(): array
{
$base = $this->transformers;
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,53 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\GeneratorConfig;
use Dedoc\Scramble\Scramble;
use Illuminate\Routing\Router;
use LogicException;
class GeneratorConfigCollection
{
/**
* @var array<string, GeneratorConfig>
*/
private array $apis = [];
public function __construct()
{
$this->apis[Scramble::DEFAULT_API] = $this->buildDefaultApiConfiguration();
}
private function buildDefaultApiConfiguration(): GeneratorConfig
{
return (new GeneratorConfig)
->expose(
ui: fn (Router $router, $action) => $router->get('docs/api', $action)->name('scramble.docs.ui'),
document: fn (Router $router, $action) => $router->get('docs/api.json', $action)->name('scramble.docs.document'),
);
}
public function get(string $name): GeneratorConfig
{
if (! array_key_exists($name, $this->apis)) {
throw new LogicException("$name API is not registered. Register the API using `Scramble::registerApi` first.");
}
return $this->apis[$name];
}
public function register(string $name, array $config): GeneratorConfig
{
$this->apis[$name] = $generatorConfig = $this->apis[Scramble::DEFAULT_API]
->cloneWithoutExposing()
->useConfig(array_merge(config('scramble') ?: [], $config));
return $generatorConfig;
}
public function all(): array
{
return $this->apis;
}
}
+90
View File
@@ -0,0 +1,90 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Infer\Configuration\ClassLike;
use Dedoc\Scramble\Infer\Configuration\DefinitionMatcher;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
/**
* @internal
*/
class InferConfig
{
/** @var DefinitionMatcher[] */
private array $forcedAstSourcedDefinitionsMatchers = [];
/** @var DefinitionMatcher[] */
private array $forcedReflectionSourcedDefinitionsMatchers = [];
/**
* @param (string|DefinitionMatcher)[] $items
* @return $this
*/
public function buildDefinitionsUsingAstFor(array $items): static
{
$this->forcedAstSourcedDefinitionsMatchers = [
...$this->forcedAstSourcedDefinitionsMatchers,
...array_map(
fn ($item) => is_string($item) ? new ClassLike($item) : $item,
Arr::wrap($items),
),
];
return $this;
}
/**
* @param (string|DefinitionMatcher)[] $items
* @return $this
*/
public function buildDefinitionsUsingReflectionFor(array $items): static
{
$this->forcedReflectionSourcedDefinitionsMatchers = [
...$this->forcedReflectionSourcedDefinitionsMatchers,
...array_map(
fn ($item) => is_string($item) ? new ClassLike($item) : $item,
Arr::wrap($items),
),
];
return $this;
}
/**
* @param DefinitionMatcher[] $matchers
*/
private function matchesAnyDefinitionMatcher(string $class, array $matchers): bool
{
foreach ($matchers as $item) {
if ($item->matches($class)) {
return true;
}
}
return false;
}
public function shouldAnalyzeAst(string $class): bool
{
if ($this->matchesAnyDefinitionMatcher($class, $this->forcedReflectionSourcedDefinitionsMatchers)) {
return false;
}
if ($this->matchesAnyDefinitionMatcher($class, $this->forcedAstSourcedDefinitionsMatchers)) {
return true;
}
// Ignoring static analysis error due to it is fine to exception to be thrown here if bad input.
$reflection = new \ReflectionClass($class); // @phpstan-ignore argument.type
$path = $reflection->getFileName();
if (! $path) {
return true; // Keep in mind the internal classes are analyzed via AST analyzer
}
return ! Str::contains($path, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR);
}
}
@@ -0,0 +1,24 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Enums\JsonApiArraySerialization;
use Illuminate\Http\Resources\JsonApi\JsonApiResource;
class JsonApiConfig
{
public function __construct(
public readonly JsonApiArraySerialization $arraySerialization = JsonApiArraySerialization::Comma,
public readonly ?int $maxRelationshipDepth = null,
) {}
/** @return non-negative-int */
public function maxRelationshipDepth(): int
{
if ($this->maxRelationshipDepth === null) {
return JsonApiResource::$maxRelationshipDepth; // @phpstan-ignore return.type
}
return max(0, min($this->maxRelationshipDepth, JsonApiResource::$maxRelationshipDepth));
}
}
@@ -0,0 +1,69 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\OperationTransformer;
use Dedoc\Scramble\Support\OperationExtensions\DeprecationExtension;
use Dedoc\Scramble\Support\OperationExtensions\ErrorResponsesExtension;
use Dedoc\Scramble\Support\OperationExtensions\RequestBodyExtension;
use Dedoc\Scramble\Support\OperationExtensions\RequestEssentialsExtension;
use Dedoc\Scramble\Support\OperationExtensions\ResponseExtension;
use Dedoc\Scramble\Support\OperationExtensions\ResponseHeadersExtension;
use Illuminate\Support\Arr;
class OperationTransformers
{
protected array $transformers = [];
protected array $appends = [];
protected array $prepends = [];
public function append(array|callable|string $transformers)
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($transformers)
);
return $this;
}
public function prepend(array|callable|string $transformers)
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($transformers)
);
return $this;
}
public function use(array $transformers)
{
$this->transformers = $transformers;
return $this;
}
/**
* @return list<callable|class-string<OperationTransformer>>
*/
public function all(): array
{
$base = $this->transformers ?: [
RequestEssentialsExtension::class,
RequestBodyExtension::class,
ErrorResponsesExtension::class,
ResponseExtension::class,
ResponseHeadersExtension::class,
DeprecationExtension::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,88 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\AttributesParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\FormRequestParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\JsonApiResourceParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\MethodCallsParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\ParameterExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\PathParametersExtractor;
use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\ValidateCallParametersExtractor;
use Illuminate\Support\Arr;
class ParametersExtractors
{
/** @var class-string<ParameterExtractor>[] */
protected array $extractors = [];
/** @var class-string<ParameterExtractor>[] */
protected array $appends = [];
/** @var class-string<ParameterExtractor>[] */
protected array $prepends = [];
/**
* @param class-string<ParameterExtractor>[]|class-string<ParameterExtractor> $extractor
* @return $this
*/
public function append(array|string $extractor): self
{
$this->appends = array_merge(
$this->appends,
Arr::wrap($extractor)
);
return $this;
}
/**
* @param class-string<ParameterExtractor>[]|class-string<ParameterExtractor> $extractor
* @return $this
*/
public function prepend(array|string $extractor): self
{
$this->prepends = array_merge(
$this->prepends,
Arr::wrap($extractor)
);
return $this;
}
/**
* @param class-string<ParameterExtractor>[] $extractors
* @return $this
*/
public function use(array $extractors): self
{
$this->extractors = $extractors;
return $this;
}
/**
* @return class-string<ParameterExtractor>[]
*/
public function all(): array
{
$base = $this->extractors ?: [
PathParametersExtractor::class,
FormRequestParametersExtractor::class,
ValidateCallParametersExtractor::class,
JsonApiResourceParametersExtractor::class,
];
$defaultAppends = [
MethodCallsParametersExtractor::class,
AttributesParametersExtractor::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
...$defaultAppends,
]));
}
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Illuminate\Support\Arr;
class RendererConfig
{
public readonly string $view;
private array $config;
/**
* @param array{view: string} $config
*/
public function __construct(
array $config = []
) {
$this->config = Arr::except($config, ['view']);
$this->view = $config['view'];
}
public function get(string $key, mixed $default = null): mixed
{
return Arr::get($this->config, $key, $default);
}
public function all(array $except = []): array
{
return Arr::except($this->config, $except);
}
}
@@ -0,0 +1,97 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Contracts\AllRulesSchemasTransformer;
use Dedoc\Scramble\Contracts\RuleTransformer;
use Dedoc\Scramble\RuleTransformers\ConfirmedRule;
use Dedoc\Scramble\RuleTransformers\EnumRule;
use Dedoc\Scramble\RuleTransformers\ExistsRule;
use Dedoc\Scramble\RuleTransformers\InRule;
use Dedoc\Scramble\RuleTransformers\RegexRule;
use Dedoc\Scramble\Support\ContainerUtils;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class RuleTransformers
{
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $transformers = [];
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $appends = [];
/** @var list<class-string<RuleTransformer|AllRulesSchemasTransformer>> */
protected array $prepends = [];
/**
* @param class-string<RuleTransformer|AllRulesSchemasTransformer>|list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function append(array|string $transformers): self
{
$this->appends = array_values(array_merge( // @phpstan-ignore arrayValues.list
$this->appends,
Arr::wrap($transformers)
));
return $this;
}
/**
* @param class-string<RuleTransformer|AllRulesSchemasTransformer>|list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function prepend(array|string $transformers): self
{
$this->prepends = array_values(array_merge( // @phpstan-ignore arrayValues.list
$this->prepends,
Arr::wrap($transformers)
));
return $this;
}
/**
* @param list<class-string<RuleTransformer|AllRulesSchemasTransformer>> $transformers
*/
public function use(array $transformers): self
{
$this->transformers = $transformers;
return $this;
}
/**
* @template TExtensionType of object
*
* @param class-string<TExtensionType> $type
* @param array<string, mixed> $contextfulBindings
* @return Collection<int, TExtensionType>
*/
public function instances(string $type, array $contextfulBindings): Collection
{
return collect($this->all()) // @phpstan-ignore return.type
->filter(fn ($class) => is_a($class, $type, true))
->map(fn ($class) => ContainerUtils::makeContextable($class, $contextfulBindings))
->values();
}
/**
* @return list<class-string<RuleTransformer|AllRulesSchemasTransformer>>
*/
public function all(): array
{
$base = $this->transformers ?: [
EnumRule::class,
InRule::class,
ConfirmedRule::class,
ExistsRule::class,
RegexRule::class,
];
return array_values(array_unique([
...$this->prepends,
...$base,
...$this->appends,
], SORT_REGULAR));
}
}
@@ -0,0 +1,17 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\GeneratorConfig;
use Illuminate\Support\Collection;
class SecurityDocumentationContext
{
/**
* @param Collection<int, \Illuminate\Routing\Route> $routes
*/
public function __construct(
public readonly Collection $routes,
public readonly GeneratorConfig $config,
) {}
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Configuration;
use Dedoc\Scramble\Support\Generator\ServerVariable;
class ServerVariables
{
/**
* @param array<string, ServerVariable> $items
*/
public function __construct(public array $items = []) {}
public function use(array $items)
{
$this->items = $items;
return $this;
}
public function all()
{
return $this->items;
}
public function set(string $name, ServerVariable $serverVariable)
{
$this->items[$name] = $serverVariable;
return $this;
}
}
@@ -0,0 +1,141 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Components\TermsOfContentItem;
use Dedoc\Scramble\Exceptions\ConsoleRenderable;
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Routing\Route;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Throwable;
class AnalyzeDocumentation extends Command
{
protected $signature = 'scramble:analyze
{--api=default : The API to analyze}
';
protected $description = 'Analyzes the documentation generation process to surface any issues.';
public function handle(Generator $generator): int
{
$generator->setThrowExceptions(false);
$generator(Scramble::getGeneratorConfig($this->option('api')));
$i = 1;
$this->groupExceptions($generator->exceptions)->each(function (Collection $exceptions, string $group) use (&$i) {
$this->renderExceptionsGroup($exceptions, $group, $i);
});
if (count($generator->exceptions)) {
$this->error('[ERROR] Found '.count($generator->exceptions).' errors.');
return static::FAILURE;
}
$this->info('Everything is fine! Documentation is generated without any errors 🍻');
return static::SUCCESS;
}
/**
* @return Collection<string, Collection<int, Throwable>>
*/
private function groupExceptions(array $exceptions): Collection
{
return collect($exceptions)
->groupBy(fn ($e) => $e instanceof RouteAware ? $this->getRouteKey($e->getRoute()) : '');
}
/**
* @param Collection<int, Throwable> $exceptions
*/
private function renderExceptionsGroup(Collection $exceptions, string $group, int &$i): void
{
// when route key is set, then the exceptions in the group are route aware.
if ($group) {
$this->renderRouteExceptionsGroupLine($exceptions);
}
$exceptions->each(function ($exception) use (&$i) {
$this->renderException($exception, $i);
$i++;
$this->line('');
});
}
private function getRouteKey(?Route $route): string
{
if (! $route) {
return '';
}
$method = implode('|', $route->methods());
$action = $route->getAction('uses');
return "$method.$action";
}
/**
* @param Collection<int, RouteAware> $exceptions
*/
private function renderRouteExceptionsGroupLine(Collection $exceptions): void
{
$firstException = $exceptions->first();
$route = $firstException->getRoute();
$method = implode('|', $route->methods());
$errorsMessage = ($count = $exceptions->count()).' '.Str::plural('error', $count);
$tocComponent = new TermsOfContentItem(
right: '<options=bold;fg='.$this->getHttpMethodColor($method).'>'.$method."</> $route->uri <fg=red>$errorsMessage</>",
left: $this->getRouteAction($route),
);
$tocComponent->render($this->output);
$this->line('');
}
private function getHttpMethodColor(string $method): string
{
return match ($method) {
'POST', 'PUT' => 'blue',
'DELETE' => 'red',
default => 'yellow',
};
}
public function getRouteAction(?Route $route): ?string
{
if (! $uses = $route->getAction('uses')) {
return null;
}
if (count($parts = explode('@', $uses)) !== 2 || ! method_exists(...$parts)) {
return null;
}
[$class, $method] = $parts;
$eloquentClassName = Str::replace(['App\Http\Controllers\\', 'App\Http\\'], '', $class);
return "<fg=gray>{$eloquentClassName}@{$method}</>";
}
private function renderException(Throwable $exception, int $i): void
{
$message = Str::replace('Dedoc\Scramble\Support\Generator\Types\\', '', property_exists($exception, 'originalMessage') ? $exception->originalMessage : $exception->getMessage()); // @phpstan-ignore argument.templateType
$this->output->writeln("<options=bold>$i. {$message}</>");
if ($exception instanceof ConsoleRenderable) {
$exception->renderInConsole($this->output);
}
}
}
@@ -0,0 +1,41 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Concerns\ManagesDocumentationCache;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
class CacheDocumentation extends Command
{
use ManagesDocumentationCache;
protected $signature = 'scramble:cache
{--api=* : The API to cache a documentation for (by default, caches all APIs)}
';
protected $description = 'Cache the generated OpenAPI document.';
public function handle(Generator $generator): int
{
if (! $this->ensureDocumentationCacheConfigured()) {
return self::SUCCESS;
}
$store = config('scramble.cache.store');
foreach ($this->resolveApis() as $api) {
$config = Scramble::getGeneratorConfig($api);
cache()->store($store)->forever(
$this->cacheKey($api),
$generator($config),
);
$this->info("OpenAPI document cached for [{$api}] API.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,34 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Console\Commands\Concerns\ManagesDocumentationCache;
use Illuminate\Console\Command;
class ClearDocumentationCache extends Command
{
use ManagesDocumentationCache;
protected $signature = 'scramble:clear
{--api=* : The API to clear a documentation cache for (by default, clears all APIs)}
';
protected $description = 'Clear the cached OpenAPI document.';
public function handle(): int
{
if (! $this->ensureDocumentationCacheConfigured()) {
return self::SUCCESS;
}
$store = config('scramble.cache.store');
foreach ($this->resolveApis() as $api) {
cache()->store($store)->forget($this->cacheKey($api));
$this->info("OpenAPI document cache cleared for [{$api}] API.");
}
return self::SUCCESS;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
use NunoMaduro\Collision\Highlighter;
class Code implements Component
{
public function __construct(
public string $filePath,
public int $line,
) {}
public function render(OutputStyle $style): void
{
$code = (new Highlighter)->highlight(file_get_contents($this->filePath), $this->line);
$style->writeln($code);
}
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
interface Component
{
public function render(OutputStyle $style): void;
}
@@ -0,0 +1,40 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Components;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Str;
use Symfony\Component\Console\Terminal;
class TermsOfContentItem implements Component
{
public function __construct(
public string $right,
public ?string $left = null,
) {}
public function render(OutputStyle $style): void
{
$width = (new Terminal)->getWidth();
$rightWidth = $this->getLineWidth($this->right ?: '');
$leftWidth = $this->getLineWidth($this->left ?: '');
if (! $leftWidth) {
$style->writeln($this->right);
return;
}
$dotsCount = max($width - $rightWidth - $leftWidth - 2, 0);
$style->writeln("{$this->right}<fg=gray> ".Str::repeat('.', $dotsCount)." </>{$this->left}");
}
private function getLineWidth(string $string)
{
$re = '/<.*?>/m';
return mb_strlen(preg_replace($re, '', $string));
}
}
@@ -0,0 +1,42 @@
<?php
namespace Dedoc\Scramble\Console\Commands\Concerns;
use Dedoc\Scramble\Scramble;
trait ManagesDocumentationCache
{
protected function ensureDocumentationCacheConfigured(): bool
{
if (config('scramble.cache.store') === null) {
$this->info('Documentation cache store is not configured. Set `scramble.cache.store` in your config.');
return false;
}
if (config('scramble.cache.key') === null) {
$this->info('Documentation cache key is not configured. Set `scramble.cache.key` in your config.');
return false;
}
return true;
}
/** @return string[] */
protected function resolveApis(): array
{
$apis = array_filter($this->option('api') ?: []);
if ($apis === [] || $apis === ['*']) {
return array_keys(Scramble::getConfigurationsInstance()->all());
}
return $apis;
}
protected function cacheKey(string $api): string
{
return config('scramble.cache.key').':'.$api;
}
}
@@ -0,0 +1,35 @@
<?php
namespace Dedoc\Scramble\Console\Commands;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ExportDocumentation extends Command
{
protected $signature = 'scramble:export
{--path= : The path to save the exported JSON file}
{--api=default : The API to export a documentation for}
';
protected $description = 'Export the OpenAPI document to a JSON file.';
public function handle(Generator $generator): void
{
$api = $this->option('api');
$path = $this->option('path');
$config = Scramble::getGeneratorConfig($api);
$specification = json_encode($generator($config), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
/** @var string $filename */
$filename = $path ?: $config->get('export_path') ?? 'api'.($api === 'default' ? '' : "-$api").'.json';
File::put($filename, $specification);
$this->info("OpenAPI document exported to {$filename}.");
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Dedoc\Scramble;
/** @internal */
class ContextReferences
{
public function __construct(
public readonly ContextReferencesCollection $schemas = new ContextReferencesCollection,
public readonly ContextReferencesCollection $responses = new ContextReferencesCollection,
public readonly ContextReferencesCollection $parameters = new ContextReferencesCollection,
public readonly ContextReferencesCollection $examples = new ContextReferencesCollection,
public readonly ContextReferencesCollection $requestBodies = new ContextReferencesCollection,
public readonly ContextReferencesCollection $headers = new ContextReferencesCollection,
public readonly ContextReferencesCollection $securitySchemes = new ContextReferencesCollection,
public readonly ContextReferencesCollection $links = new ContextReferencesCollection,
public readonly ContextReferencesCollection $callbacks = new ContextReferencesCollection,
public readonly ContextReferencesCollection $pathItems = new ContextReferencesCollection,
) {}
}
@@ -0,0 +1,79 @@
<?php
namespace Dedoc\Scramble;
use Dedoc\Scramble\Support\Generator\Reference;
use Illuminate\Support\Str;
/** @internal */
class ContextReferencesCollection
{
private array $tempNames = [];
/**
* @param array<string, Reference[]> $items The key is reference ID and the value is a list of such references.
*/
public function __construct(
public array $items = [],
) {}
public function has(string $referenceId): bool
{
return array_key_exists($referenceId, $this->items);
}
/**
* @return Reference[]
*/
public function get(string $referenceId): array
{
return $this->items[$referenceId] ?? [];
}
public function add(string $referenceId, Reference $reference): Reference
{
$this->items[$referenceId] ??= [];
$this->items[$referenceId][] = $this->setUniqueName($reference);
return $reference;
}
public function setUniqueName(Reference $reference): Reference
{
$reference->fullName = $reference->shortName ?: $this->uniqueSchemaName($reference->fullName);
return $reference;
}
public function uniqueName(string $referenceId): string
{
if ($this->has($referenceId)) {
$reference = $this->get($referenceId)[0];
return $reference->shortName ?: $reference->fullName;
}
return $this->uniqueSchemaName($referenceId);
}
private function uniqueSchemaName(string $fullName)
{
$shortestPossibleName = class_basename($fullName);
if (
($this->tempNames[$shortestPossibleName] ?? null) === null
|| ($this->tempNames[$shortestPossibleName] ?? null) === $fullName
) {
$this->tempNames[$shortestPossibleName] = $fullName;
return static::slug($shortestPossibleName);
}
return static::slug($fullName);
}
private static function slug(string $name)
{
return Str::replace('\\', '.', $name);
}
}
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\RuleTransforming\NormalizedRule;
use Dedoc\Scramble\Support\RuleTransforming\RuleTransformerContext;
use Dedoc\Scramble\Support\RuleTransforming\SchemaBag;
interface AllRulesSchemasTransformer
{
public function shouldHandle(NormalizedRule $rule): bool;
public function transformAll(SchemaBag $schemaBag, NormalizedRule $rule, RuleTransformerContext $context): void;
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
interface DocumentTransformer
{
public function handle(OpenApi $document, OpenApiContext $context);
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\RouteInfo;
interface OperationTransformer
{
public function handle(Operation $operation, RouteInfo $routeInfo);
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Dedoc\Scramble\Support\RuleTransforming\NormalizedRule;
use Dedoc\Scramble\Support\RuleTransforming\RuleTransformerContext;
interface RuleTransformer
{
public function shouldHandle(NormalizedRule $rule): bool;
public function toSchema(Type $previous, NormalizedRule $rule, RuleTransformerContext $context): Type;
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Contracts;
use Dedoc\Scramble\Configuration\SecurityDocumentationContext;
use Dedoc\Scramble\GeneratorConfig;
interface SecurityDocumentationStrategy
{
public function configure(SecurityDocumentationContext $context): GeneratorConfig;
}
@@ -0,0 +1,57 @@
<?php
namespace Dedoc\Scramble\DocumentTransformers;
use Dedoc\Scramble\Attributes\Group;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Tag;
use Illuminate\Support\Collection;
use ReflectionAttribute;
class AddDocumentTags implements DocumentTransformer
{
public function handle(OpenApi $document, OpenApiContext $context): void
{
$document->tags = $this->makeTagsFromGroupAttributes($context->groups);
}
/**
* @param Collection<int, ReflectionAttribute<Group>> $groupsAttributes
* @return Tag[]
*/
private function makeTagsFromGroupAttributes(Collection $groupsAttributes)
{
/** @var Collection<string, Tag> $tags */
$tags = $groupsAttributes->reduce(function (Collection $acc, ReflectionAttribute $attribute) {
$arguments = $attribute->getArguments();
$name = $arguments['name'] ?? $arguments[0] ?? null;
if (! $name) {
return $acc;
}
$description = $arguments['description'] ?? $arguments[1] ?? null;
$weight = $arguments['weight'] ?? $arguments[2] ?? null;
/** @var Tag $tag */
$tag = $acc->get($name, new Tag($name));
if ($description !== null && $tag->description === null) {
$tag->description = $description;
}
if ($weight !== null && $tag->getAttribute('weight') === null) {
$tag->setAttribute('weight', $weight);
}
$acc->offsetSet($name, $tag);
return $acc;
}, collect());
return $tags->sortBy(fn (Tag $t) => $t->getAttribute('weight', INF))->values()->all();
}
}
@@ -0,0 +1,36 @@
<?php
namespace Dedoc\Scramble\DocumentTransformers;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\OpenApiContext;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Illuminate\Support\Str;
class CleanupUnusedResponseReferencesTransformer implements DocumentTransformer
{
private string $serializedDocumentJson;
public function handle(OpenApi $document, OpenApiContext $context): void
{
$components = $document->components;
$responses = $components->responses;
foreach ($responses as $responseName => $reference) {
if (! $this->isResponseReferenceUsed($document, $context->references->responses->uniqueName($responseName))) {
$components->removeResponse($responseName);
}
}
}
private function isResponseReferenceUsed(OpenApi $document, string $responseName): bool
{
if (! isset($this->serializedDocumentJson)) {
$this->serializedDocumentJson = json_encode($document->toArray(), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
$referencePath = "#/components/responses/{$responseName}";
return Str::contains($this->serializedDocumentJson, $referencePath);
}
}
@@ -0,0 +1,9 @@
<?php
namespace Dedoc\Scramble\Enums;
enum JsonApiArraySerialization
{
case String;
case Comma;
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Illuminate\Console\OutputStyle;
interface ConsoleRenderable
{
public function renderInConsole(OutputStyle $outputStyle): void;
}
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Dedoc\Scramble\Console\Commands\Components\Code;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Exception;
use Illuminate\Console\OutputStyle;
use Illuminate\Routing\Route;
class InvalidSchema extends Exception implements ConsoleRenderable, RouteAware
{
use RouteAwareTrait;
public ?string $jsonPointer = null;
public string $originalMessage = '';
public ?string $originFile = null;
public ?int $originLine = null;
public static function createForSchema(string $message, string $path, Type $schema)
{
/** @var string|null $file */
$file = $schema->getAttribute('file');
/** @var int|null $line */
$line = $schema->getAttribute('line');
$originalMessage = $message;
if ($file) {
$message = rtrim($message, '.').'. Got when analyzing an expression in file ['.$file.'] on line '.$line;
}
$exception = new static($path.': '.$message);
$exception->originalMessage = $originalMessage;
$exception->originFile = $file;
$exception->originLine = $line;
$exception->jsonPointer = $path;
return $exception;
}
public function getRouteAwareMessage(Route $route, string $msg): string
{
$method = $route->methods()[0];
$action = $route->getAction('uses');
return "'$method $route->uri' ($action): ".$msg;
}
public function renderInConsole(OutputStyle $outputStyle): void
{
$codeSample = null;
$tableCells = [];
if ($this->jsonPointer) {
$tableCells[] = ['<fg=gray>Found at</>', $this->jsonPointer];
}
if ($this->originFile) {
$line = $this->originLine ? ":$this->originLine" : '';
$tableCells[] = ['<fg=gray>Inferred at</>', "{$this->originFile}{$line}"];
if ($line) {
$path = class_exists($this->originFile)
? (new \ReflectionClass($this->originFile))->getFileName()
: $this->originFile;
$codeSample = new Code($path, $this->originLine);
}
}
$outputStyle->createTable()->setRows($tableCells)->setStyle('compact')->render();
$codeSample?->render($outputStyle);
}
}
@@ -0,0 +1,5 @@
<?php
namespace Dedoc\Scramble\Exceptions;
class OpenApiReferenceTargetNotFoundException extends \Exception {}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Illuminate\Routing\Route;
interface RouteAware
{
public function setRoute(Route $route): static;
public function getRoute(): ?Route;
public function getRouteAwareMessage(Route $route, string $msg): string;
}
@@ -0,0 +1,30 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Exception;
use Illuminate\Routing\Route;
/**
* @mixin Exception
*/
trait RouteAwareTrait
{
protected ?Route $route = null;
public function setRoute(Route $route): static
{
$this->route = $route;
if (method_exists($this, 'getRouteAwareMessage')) {
$this->message = $this->getRouteAwareMessage($route, $this->getMessage());
}
return $this;
}
public function getRoute(): ?Route
{
return $this->route;
}
}
@@ -0,0 +1,64 @@
<?php
namespace Dedoc\Scramble\Exceptions;
use Exception;
use Illuminate\Support\Arr;
use Throwable;
class RulesEvaluationException extends Exception
{
/** @var array<string, Throwable> */
public array $exceptions = [];
/**
* @param array<string, Throwable> $exceptions
*/
public static function fromExceptions(array $exceptions): self
{
$exceptions = collect(Arr::wrap($exceptions))
->reduce(function (array $acc, Throwable $e, string $key) {
if ($e instanceof self) {
return array_merge($acc, $e->exceptions);
}
return [...$acc, $key => $e];
}, []);
$previous = reset($exceptions) ?: null;
$exception = new self(self::buildMessage($exceptions), previous: $previous);
$exception->exceptions = $exceptions;
return $exception;
}
/**
* @param array<string, Throwable> $exceptions
*/
private static function buildMessage(array $exceptions): string
{
$lines = [
'Cannot evaluate validation rules ('.count($exceptions).' evaluators failed):',
];
foreach ($exceptions as $key => $e) {
$name = class_basename($key);
$context = self::extractFileLocation($e);
$lines[] = " [$name] {$e->getMessage()}".($context ? " {$context}" : '');
}
return implode("\n", $lines);
}
private static function extractFileLocation(Throwable $e): ?string
{
if ($e->getFile() && $e->getLine()) {
return "(at {$e->getFile()}:{$e->getLine()})";
}
return null;
}
}
@@ -0,0 +1,29 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Type\Type;
abstract class ExceptionToResponseExtension
{
protected Infer $infer;
protected TypeTransformer $openApiTransformer;
protected Components $components;
public function __construct(Infer $infer, TypeTransformer $openApiTransformer, Components $components)
{
$this->infer = $infer;
$this->openApiTransformer = $openApiTransformer;
$this->components = $components;
}
public function toResponse(Type $type)
{
return null;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Contracts\OperationTransformer;
use Dedoc\Scramble\GeneratorConfig;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\RouteInfo;
abstract class OperationExtension implements OperationTransformer
{
public function __construct(
protected Infer $infer,
protected TypeTransformer $openApiTransformer,
protected GeneratorConfig $config
) {}
abstract public function handle(Operation $operation, RouteInfo $routeInfo);
}
@@ -0,0 +1,34 @@
<?php
namespace Dedoc\Scramble\Extensions;
use Dedoc\Scramble\Infer;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Types\Type as OpenApiType;
use Dedoc\Scramble\Support\Generator\Types\UnknownType;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Type\Type;
abstract class TypeToSchemaExtension
{
public function __construct(protected Infer $infer, protected TypeTransformer $openApiTransformer, protected Components $components) {}
/**
* @param Type $type The type being transformed to schema.
* @param ?OpenApiType $previousExtensionResult The resulting schema from a previous extension.
*/
public function toSchema(Type $type)
{
return new UnknownType;
}
/**
* @param Type $type The type being transformed to response.
* @param ?Response $previousExtensionResult The resulting response from a previous extension.
*/
public function toResponse(Type $type)
{
return null;
}
}
+373
View File
@@ -0,0 +1,373 @@
<?php
namespace Dedoc\Scramble;
use Closure;
use Dedoc\Scramble\Attributes\ExcludeAllRoutesFromDocs;
use Dedoc\Scramble\Attributes\ExcludeRouteFromDocs;
use Dedoc\Scramble\Configuration\SecurityDocumentationContext;
use Dedoc\Scramble\Contracts\DocumentTransformer;
use Dedoc\Scramble\Exceptions\RouteAware;
use Dedoc\Scramble\OpenApiVisitor\SchemaEnforceVisitor;
use Dedoc\Scramble\Support\ContainerUtils;
use Dedoc\Scramble\Support\Generator\Components;
use Dedoc\Scramble\Support\Generator\InfoObject;
use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\Operation;
use Dedoc\Scramble\Support\Generator\Path;
use Dedoc\Scramble\Support\Generator\Reference;
use Dedoc\Scramble\Support\Generator\Server;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\Generator\UniqueNameOptions;
use Dedoc\Scramble\Support\Generator\UniqueNamesOptionsCollection;
use Dedoc\Scramble\Support\OperationBuilder;
use Dedoc\Scramble\Support\RouteInfo;
use Dedoc\Scramble\Support\ServerFactory;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route as RouteFacade;
use Illuminate\Support\Str;
use InvalidArgumentException;
use ReflectionException;
use ReflectionMethod;
use Throwable;
class Generator
{
public array $exceptions = [];
protected bool $throwExceptions = true;
public function __construct(
private OperationBuilder $operationBuilder,
) {}
public function setThrowExceptions(bool $throwExceptions): static
{
$this->throwExceptions = $throwExceptions;
return $this;
}
public function __invoke(?GeneratorConfig $config = null)
{
$config ??= Scramble::getGeneratorConfig(Scramble::DEFAULT_API);
$routes = $this->getRoutes($config);
$config = $this->configureSecurityStrategy($routes, $config);
$openApi = $this->makeOpenApi($config);
$context = new OpenApiContext($openApi, $config);
$typeTransformer = $this->buildTypeTransformer($context);
$routes
->flatMap(function (Route $route, int $index) use ($openApi, $config, $typeTransformer) {
try {
$operations = $this->routeToOperations($openApi, $route, $config, $typeTransformer);
foreach ($operations as $i => $operation) {
if ($route->getAction('uses') instanceof Closure) {
$operation->setAttribute('isClosure', true);
}
$operation->setAttribute('index', $index + $i);
}
return $operations;
} catch (Throwable $e) {
if ($e instanceof RouteAware) {
$e->setRoute($route);
}
if (config('app.debug', false)) {
$method = implode('|', $route->methods());
$action = $route->getAction('uses');
if ($action instanceof Closure) {
$action = '{closure}';
}
dump("Error when analyzing route '$method $route->uri' ($action): {$e->getMessage()} ".($e->getFile().' on line '.$e->getLine()));
logger()->error("Error when analyzing route '$method $route->uri' ($action): {$e->getMessage()} ".($e->getFile().' on line '.$e->getLine()));
}
throw $e;
}
})
->filter()
->sortBy($this->createOperationsSorter())
->each(fn (Operation $operation) => $openApi->addPath(
Path::make(
$config->apiPath()->stripPrefix($operation->path)
)->addOperation($operation)
))
->toArray();
$this->setUniqueOperationId($openApi);
$this->moveSameAlternativeServersToPath($openApi);
foreach ($config->documentTransformers->all() as $openApiTransformer) {
$openApiTransformer = is_callable($openApiTransformer)
? $openApiTransformer
: ContainerUtils::makeContextable($openApiTransformer, [
TypeTransformer::class => $typeTransformer,
]);
if (is_callable($openApiTransformer)) {
$openApiTransformer($openApi, $context);
continue;
}
if ($openApiTransformer instanceof DocumentTransformer) {
$openApiTransformer->handle($openApi, $context);
continue;
}
// @phpstan-ignore deadCode.unreachable
throw new InvalidArgumentException('(callable(OpenApi, OpenApiContext): void)|DocumentTransformer type for document transformer expected, received '.$openApiTransformer::class);
}
return $openApi->toArray();
}
private function configureSecurityStrategy(Collection $routes, GeneratorConfig $config): GeneratorConfig
{
$strategy = $config->securityStrategy();
if (! $strategy) {
return $config;
}
return $strategy->configure(new SecurityDocumentationContext($routes, $config->cloneWithoutExposing()));
}
private function createOperationsSorter(): array
{
$defaultSortValue = fn (Operation $o) => $o->tags[0] ?? null;
return [
fn (Operation $a, Operation $b) => $a->getAttribute('groupWeight', INF) <=> $b->getAttribute('groupWeight', INF),
fn (Operation $a, Operation $b) => $a->getAttribute('weight', INF) <=> $b->getAttribute('weight', INF), // @todo manual endpoint sorting
fn (Operation $a, Operation $b) => $defaultSortValue($a) <=> $defaultSortValue($b),
fn (Operation $a, Operation $b) => $a->getAttribute('index', INF) <=> $b->getAttribute('index', INF),
];
}
private function makeOpenApi(GeneratorConfig $config)
{
$openApi = OpenApi::make('3.1.0')
->setComponents(new Components)
->setInfo(
InfoObject::make($config->get('ui.title', $default = config('app.name')) ?: $default)
->setVersion($config->get('info.version', '0.0.1'))
->setDescription($config->get('info.description', ''))
);
[$defaultProtocol] = explode('://', url('/'));
$servers = $config->get('servers') ?: [
'' => ($domain = $config->get('api_domain'))
? $defaultProtocol.'://'.$domain.'/'.$config->apiPath()->serverPath()
: $config->apiPath()->serverPath(),
];
foreach ($servers as $description => $url) {
$openApi->addServer(
(new ServerFactory($config->serverVariables->all()))->make(url($url ?: '/'), $description)
);
}
return $openApi;
}
/**
* @return Collection<int, Route>
*/
private function getRoutes(GeneratorConfig $config): Collection
{
return collect(RouteFacade::getRoutes())
->pipe(function (Collection $c) {
$onlyRoutes = $c->filter(function (Route $route) {
if (! is_string($route->getAction('controller'))) {
return false;
}
if (! is_string($route->getAction('uses'))) {
return false;
}
try {
$reflection = new ReflectionMethod(...explode('@', $route->getAction('uses')));
if (str_contains($reflection->getDocComment() ?: '', '@only-docs')) {
return true;
}
} catch (Throwable) {
}
return false;
});
return $onlyRoutes->count() ? $onlyRoutes : $c;
})
->filter(function (Route $route) {
return ! ($name = $route->getAction('as')) || ! Str::startsWith($name, 'scramble');
})
->filter($config->routes())
->filter(function (Route $route) {
if (! is_string($route->getAction('uses'))) {
return true;
}
try {
$reflection = new ReflectionMethod(...explode('@', $route->getAction('uses')));
} catch (ReflectionException) {
/*
* If route is registered but route method doesn't exist, it will not be included
* in the resulting documentation.
*/
return false;
}
if (count($reflection->getAttributes(ExcludeRouteFromDocs::class))) {
return false;
}
if (count($reflection->getDeclaringClass()->getAttributes(ExcludeAllRoutesFromDocs::class))) {
return false;
}
return true;
})
->values();
}
private function buildTypeTransformer(OpenApiContext $context): TypeTransformer
{
return app()->make(TypeTransformer::class, [
'context' => $context,
]);
}
/** @return Operation[] */
private function routeToOperations(OpenApi $openApi, Route $route, GeneratorConfig $config, TypeTransformer $typeTransformer): array
{
$methods = array_map('strtolower', Arr::wrap(($config->operationMethodsResolver)($route)));
$operations = [];
foreach ($methods as $method) {
$routeInfo = new RouteInfo($route, $method);
$operation = $this->operationBuilder->build($routeInfo, $openApi, $config, $typeTransformer);
$this->ensureSchemaTypes($route, $operation);
$operations[] = $operation;
}
return $operations;
}
private function ensureSchemaTypes(Route $route, Operation $operation): void
{
if (! Scramble::getSchemaValidator()->hasRules()) {
return;
}
[$traverser, $visitor] = $this->createSchemaEnforceTraverser($route);
$traverser->traverse($operation, ['', 'paths', $operation->path, $operation->method]);
$references = $visitor->popReferences();
/** @var Reference $ref */
foreach ($references as $ref) {
if ($resolvedType = $ref->resolve()) {
$traverser->traverse($resolvedType, ['', 'components', $ref->referenceType, $ref->getUniqueName()]);
}
}
}
private function createSchemaEnforceTraverser(Route $route)
{
$traverser = new OpenApiTraverser([$visitor = new SchemaEnforceVisitor($route, $this->throwExceptions, $this->exceptions)]);
return [$traverser, $visitor];
}
private function moveSameAlternativeServersToPath(OpenApi $openApi)
{
foreach (collect($openApi->paths)->groupBy('path') as $pathsGroup) {
if ($pathsGroup->isEmpty()) {
continue;
}
$operations = collect($pathsGroup->pluck('operations')->flatten());
$operationsHaveSameAlternativeServers = $operations->count()
&& $operations->every(fn (Operation $o) => count($o->servers))
&& $operations->unique(function (Operation $o) {
return collect($o->servers)->map(fn (Server $s) => $s->url)->join('.');
})->count() === 1;
if (! $operationsHaveSameAlternativeServers) {
continue;
}
$pathsGroup->every->servers($operations->first()->servers);
foreach ($operations as $operation) {
$operation->servers([]);
}
}
}
private function setUniqueOperationId(OpenApi $openApi)
{
$names = new UniqueNamesOptionsCollection;
$this->foreachOperation($openApi, function (Operation $operation) use ($names) {
if ($operation->operationId) {
return;
}
$names->push($operation->getAttribute('operationId')); // @phpstan-ignore argument.type
});
$this->foreachOperation($openApi, function (Operation $operation, $index) use ($names) {
if ($operation->operationId) {
return;
}
$name = $operation->getAttribute('operationId');
if (! $name instanceof UniqueNameOptions) {
return;
}
if (! $name->eloquent && $operation->getAttribute('isClosure')) {
return;
}
$operation->setOperationId($names->getUniqueName($name, function (string $fallback) use ($index) {
return "{$fallback}_{$index}";
}));
});
}
private function foreachOperation(OpenApi $openApi, callable $callback)
{
foreach (collect($openApi->paths)->groupBy('path') as $pathsGroup) {
if ($pathsGroup->isEmpty()) {
continue;
}
$operations = collect($pathsGroup->pluck('operations')->flatten());
foreach ($operations as $index => $operation) {
$callback($operation, $index);
}
}
}
}
+319
View File
@@ -0,0 +1,319 @@
<?php
namespace Dedoc\Scramble;
use Closure;
use Dedoc\Scramble\Configuration\ApiPath;
use Dedoc\Scramble\Configuration\DocumentTransformers;
use Dedoc\Scramble\Configuration\JsonApiConfig;
use Dedoc\Scramble\Configuration\OperationTransformers;
use Dedoc\Scramble\Configuration\ParametersExtractors;
use Dedoc\Scramble\Configuration\RendererConfig;
use Dedoc\Scramble\Configuration\RuleTransformers;
use Dedoc\Scramble\Configuration\ServerVariables;
use Dedoc\Scramble\Contracts\AllRulesSchemasTransformer;
use Dedoc\Scramble\Contracts\RuleTransformer;
use Dedoc\Scramble\Contracts\SecurityDocumentationStrategy;
use Dedoc\Scramble\Enums\JsonApiArraySerialization;
use Dedoc\Scramble\Support\Generator\ServerVariable;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Support\Arr;
use InvalidArgumentException;
use ReflectionFunction;
use ReflectionNamedType;
class GeneratorConfig
{
/**
* @var (Closure(Router, mixed): Route)|string|null
*/
public Closure|string|null $uiRoute = null;
/**
* @var (Closure(Router, mixed): Route)|string|null
*/
public Closure|string|null $documentRoute = null;
/**
* @var Closure(Route): (string|string[])
*/
public Closure $operationMethodsResolver;
public function __construct(
private array $config = [],
private ?Closure $routeResolver = null,
public readonly ParametersExtractors $parametersExtractors = new ParametersExtractors,
public readonly OperationTransformers $operationTransformers = new OperationTransformers,
public readonly DocumentTransformers $documentTransformers = new DocumentTransformers,
public readonly RuleTransformers $ruleTransformers = new RuleTransformers,
public readonly ServerVariables $serverVariables = new ServerVariables,
?Closure $operationMethodsResolver = null,
public JsonApiConfig $jsonApi = new JsonApiConfig,
private ?ApiPath $resolvedApiPath = null,
) {
$this->operationMethodsResolver = $operationMethodsResolver ?: fn (Route $r) => $r->methods()[0];
}
public function config(array $config)
{
$this->config = $config;
$this->resolvedApiPath = null;
return $this;
}
public function routes(?Closure $routeResolver = null)
{
if (count(func_get_args()) === 0) {
return $this->routeResolver ?: $this->defaultRoutesFilter(...);
}
if ($routeResolver) {
$this->routeResolver = $routeResolver;
}
return $this;
}
public function renderer(): RendererConfig
{
if (Arr::has($this->config, 'ui.logo')) {
return new RendererConfig(array_merge(
$this->get('renderers.elements', []),
$this->get('ui'),
));
}
return new RendererConfig($this->get('renderers.'.$this->get('renderer'), []));
}
/**
* @param (Closure(Router, mixed): Route)|string|false $ui
* @param (Closure(Router, mixed): Route)|string|false $document
*/
public function expose(Closure|string|false $ui = false, Closure|string|false $document = false): static
{
if (count(func_get_args()) === 1 && isset(func_get_args()[0]) && func_get_args()[0] === false) {
$this->uiRoute = null;
$this->documentRoute = null;
return $this;
}
$this->uiRoute = $ui ?: null;
$this->documentRoute = $document ?: null;
return $this;
}
private function defaultRoutesFilter(Route $route)
{
$expectedDomain = $this->get('api_domain');
return $this->apiPath()->matches($route->uri)
&& (! $expectedDomain || $route->getDomain() === $expectedDomain);
}
public function apiPath(): ApiPath
{
return $this->resolvedApiPath ??= ApiPath::from($this->get('api_path'), 'api');
}
public function afterOpenApiGenerated(?callable $afterOpenApiGenerated = null)
{
if (count(func_get_args()) === 0) {
return $this->documentTransformers->all();
}
if ($afterOpenApiGenerated) {
$this->documentTransformers->append($afterOpenApiGenerated);
}
return $this;
}
public function useConfig(array $config): static
{
$this->config = $config;
$this->resolvedApiPath = null;
return $this;
}
public function cloneWithoutExposing(): static
{
return new GeneratorConfig(
config: $this->config,
routeResolver: $this->routeResolver,
parametersExtractors: clone $this->parametersExtractors,
operationTransformers: clone $this->operationTransformers,
documentTransformers: clone $this->documentTransformers,
ruleTransformers: clone $this->ruleTransformers,
serverVariables: clone $this->serverVariables,
operationMethodsResolver: $this->operationMethodsResolver,
jsonApi: clone $this->jsonApi,
);
}
public function withParametersExtractors(callable $callback): static
{
$callback($this->parametersExtractors);
return $this;
}
public function withOperationTransformers(array|string|callable $cb): static
{
if ($this->isOperationTransformerMapper($cb)) {
$cb($this->operationTransformers);
return $this;
}
$this->operationTransformers->append($cb);
return $this;
}
/**
* @param list<class-string<RuleTransformer|AllRulesSchemasTransformer>>|class-string<RuleTransformer|AllRulesSchemasTransformer>|(callable(RuleTransformers): void) $cb
*/
public function withRuleTransformers(array|string|callable $cb): self
{
if (is_callable($cb)) {
$cb($this->ruleTransformers);
return $this;
}
$this->ruleTransformers->append($cb);
return $this;
}
private function isOperationTransformerMapper($cb): bool
{
if (! $cb instanceof Closure) {
return false;
}
$reflection = new ReflectionFunction($cb);
return count($reflection->getParameters()) === 1
&& $reflection->getParameters()[0]->getType() instanceof ReflectionNamedType
&& is_a($reflection->getParameters()[0]->getType()->getName(), OperationTransformers::class, true);
}
public function withDocumentTransformers(array|string|callable $cb): static
{
if ($this->isDocumentTransformerMapper($cb)) {
$cb($this->documentTransformers);
return $this;
}
$this->documentTransformers->append($cb);
return $this;
}
private function isDocumentTransformerMapper($cb): bool
{
if (! $cb instanceof Closure) {
return false;
}
$reflection = new ReflectionFunction($cb);
return count($reflection->getParameters()) === 1
&& $reflection->getParameters()[0]->getType() instanceof ReflectionNamedType
&& is_a($reflection->getParameters()[0]->getType()->getName(), DocumentTransformers::class, true);
}
/**
* @param (callable(ServerVariables): void)|array<string, ServerVariable> $variables
*/
public function withServerVariables(callable|array $variables)
{
if (is_callable($variables)) {
$variables($this->serverVariables);
return $this;
}
$this->serverVariables->use($variables);
return $this;
}
/**
* Force Scramble to use `PATCH` for `PUT|PATCH` routes.
*/
public function preferPatchMethod(): static
{
return $this->resolveOperationMethodsUsing(function (Route $route): string {
$methods = array_map('strtolower', $route->methods());
return in_array('patch', $methods) && in_array('put', $methods) ? 'patch' : $methods[0];
});
}
/**
* @param Closure(Route): (string|string[]) $resolver
*/
public function resolveOperationMethodsUsing(Closure $resolver): static
{
$this->operationMethodsResolver = $resolver;
return $this;
}
public function jsonApi(
JsonApiArraySerialization $arraySerialization = JsonApiArraySerialization::Comma,
?int $maxRelationshipDepth = null,
): static {
$this->jsonApi = new JsonApiConfig(
arraySerialization: $arraySerialization,
maxRelationshipDepth: $maxRelationshipDepth,
);
return $this;
}
public function get(string $key, mixed $default = null)
{
return Arr::get($this->config, $key, $default);
}
public function securityStrategy(): ?SecurityDocumentationStrategy
{
$value = $this->get('security_strategy');
if ($value === null) {
return null;
}
[$class, $options] = is_string($value)
? [$value, []]
: (is_array($value) && count($value) === 2 && is_string($value[0])
? [$value[0], $value[1]]
: [null, []]);
if ($class === null) {
throw new InvalidArgumentException(
'Invalid scramble.security_strategy config. Expected null, a class-string, or [class-string, options array].'
);
}
$strategy = app($class, $options);
if (! $strategy instanceof SecurityDocumentationStrategy) {
throw new InvalidArgumentException(
"Security strategy [{$class}] must implement ".SecurityDocumentationStrategy::class.'.'
);
}
return $strategy;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Http\Middleware;
use Illuminate\Support\Facades\Gate;
class RestrictedDocsAccess
{
public function handle($request, \Closure $next)
{
if (app()->environment('local')) {
return $next($request);
}
if (Gate::allows('viewApiDocs')) {
return $next($request);
}
abort(403);
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble;
use Dedoc\Scramble\Configuration\InferConfig;
use Dedoc\Scramble\Infer\Analyzer\ClassAnalyzer;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Scope\Index;
class Infer
{
public function __construct(
public Index $index,
public InferConfig $config,
) {}
public function analyzeClass(string $class): ClassDefinition
{
if (! $this->index->getClassDefinition($class)) {
$this->index->registerClassDefinition(
(new ClassAnalyzer($this->index))->analyze($class)
);
}
return $this->index->getClassDefinition($class);
}
public function configure(): InferConfig
{
return $this->config;
}
}
@@ -0,0 +1,109 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Definition\AttributeDefinition;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\ClassPropertyDefinition;
use Dedoc\Scramble\Infer\Definition\PendingDocComment;
use Dedoc\Scramble\Infer\Definition\PropertyVisibility;
use Dedoc\Scramble\Infer\Extensions\Event\ClassDefinitionCreatedEvent;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Str;
use ReflectionClass;
class ClassAnalyzer
{
public function __construct(private Index $index) {}
/**
* @throws \ReflectionException
*/
public function analyze(string $name): ClassDefinition
{
$classReflection = new ReflectionClass($name); // @phpstan-ignore argument.type
$parentName = ($classReflection->getParentClass() ?: null)?->name;
$parentDefinition = $parentName ? $this->index->getClass($parentName) : null;
$classDefinition = new ClassDefinition(
name: $name,
templateTypes: $parentDefinition?->templateTypes ?: [],
properties: array_map(fn ($pd) => clone $pd, $parentDefinition?->properties ?: []),
methods: array_map(fn ($md) => $md->copyFromParent(), $parentDefinition?->methods ?: []),
parentFqn: $parentName,
);
/*
* Traits get analyzed by embracing default behavior of PHP reflection: reflection properties and
* reflection methods get copied into the class that uses the trait.
*/
foreach ($classReflection->getProperties() as $reflectionProperty) {
if ($reflectionProperty->class !== $name) {
continue;
}
if (array_key_exists($reflectionProperty->name, $classDefinition->properties)) {
$classDefinition->properties[$reflectionProperty->name]->defaultType = $reflectionProperty->hasDefaultValue()
? PropertyAnalyzer::from($reflectionProperty)->getDefaultType()
: null;
continue;
}
if ($reflectionProperty->isStatic()) {
$classDefinition->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $reflectionProperty->hasDefaultValue()
? (TypeHelper::createTypeFromValue($reflectionProperty->getDefaultValue()) ?: new UnknownType)
: new UnknownType,
isStatic: $reflectionProperty->isStatic(),
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $name)
: null,
);
} else {
$expectedTemplateTypeName = 'T'.Str::studly($reflectionProperty->name);
$existingPropertyTemplateType = collect($classDefinition->templateTypes)
->first(fn (TemplateType $t) => $t->name === $expectedTemplateTypeName);
$propertyTemplateType = $existingPropertyTemplateType ?: new TemplateType(
$expectedTemplateTypeName,
is: ($reflectionPropertyType = $reflectionProperty->getType()) ? TypeHelper::createTypeFromReflectionType($reflectionPropertyType) : new UnknownType,
);
$classDefinition->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $propertyTemplateType,
defaultType: $reflectionProperty->hasDefaultValue()
? PropertyAnalyzer::from($reflectionProperty)->getDefaultType()
: null,
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $name)
: null,
);
if (! $existingPropertyTemplateType) {
$classDefinition->templateTypes[] = $propertyTemplateType;
}
}
}
$classDefinition->setIndex($this->index);
$this->index->registerClassDefinition($classDefinition);
Context::getInstance()->extensionsBroker->afterClassDefinitionCreated(new ClassDefinitionCreatedEvent($classDefinition->name, $classDefinition));
return $classDefinition;
}
}
@@ -0,0 +1,46 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder;
use Dedoc\Scramble\Infer\Reflector\ClassReflector;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
class MethodAnalyzer
{
public function __construct(
private Index $index,
private ClassDefinition $classDefinition,
) {}
public function analyze(FunctionLikeDefinition $methodDefinition, array $indexBuilders = [], bool $withSideEffects = false)
{
try {
$node = $this->getClassReflector()->getMethod($methodDefinition->type->name)->getAstNode();
} catch (\ReflectionException) {
return null;
}
if (! $node) {
return $methodDefinition;
}
return $this->classDefinition->methods[$methodDefinition->type->name] = (new FunctionLikeAstDefinitionBuilder(
$methodDefinition->type->name,
$node,
$this->index,
new FileNameResolver($this->getClassReflector()->getNameContext()),
$this->classDefinition,
$indexBuilders,
$withSideEffects
))->build();
}
private function getClassReflector(): ClassReflector
{
return ClassReflector::make($this->classDefinition->name);
}
}
@@ -0,0 +1,74 @@
<?php
namespace Dedoc\Scramble\Infer\Analyzer;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Reflector\PropertyReflector;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Infer\TypeInferer;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Illuminate\Support\Arr;
use PhpParser\Node\PropertyItem;
use PhpParser\NodeTraverser;
use ReflectionProperty;
class PropertyAnalyzer
{
public function __construct(private ReflectionProperty $reflectionProperty) {}
public static function from(ReflectionProperty $reflectionProperty): self
{
return new self($reflectionProperty);
}
public function getDefaultType(): ?Type
{
if (! $this->reflectionProperty->hasDefaultValue()) {
return null;
}
if ($astType = $this->getDefaultTypeFromAst()) {
return $astType;
}
return TypeHelper::createTypeFromValue($this->reflectionProperty->getDefaultValue());
}
private function getDefaultTypeFromAst(): ?Type
{
$reflector = PropertyReflector::make($this->reflectionProperty->getDeclaringClass()->name, $this->reflectionProperty->name);
$astNode = $reflector->getAstNode();
if (! $astNode) {
return null;
}
$propertyItem = collect($astNode->props)->first(fn (PropertyItem $p) => $p->name->name === $this->reflectionProperty->name);
if (! $propertyItem instanceof PropertyItem) {
return null;
}
$index = app(Index::class);
$traverser = new NodeTraverser;
$nameResolver = new FileNameResolver($reflector->getClassReflector()->getNameContext());
$traverser->addVisitor($inferer = new TypeInferer(
$index,
$nameResolver,
$scope = new Scope($index, new NodeTypesResolver, new ScopeContext(new ClassDefinition($this->reflectionProperty->getDeclaringClass()->name)), $nameResolver),
Context::getInstance()->extensionsBroker->extensions,
));
$traverser->traverse(Arr::wrap($propertyItem));
if (! $propertyItem->default) {
return null;
}
return $scope->getType($propertyItem->default);
}
}
@@ -0,0 +1,53 @@
<?php
namespace Dedoc\Scramble\Infer;
use Dedoc\Scramble\Infer\Contracts\ArgumentTypeBag;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
class AutoResolvingArgumentTypeBag implements ArgumentTypeBag
{
/**
* @param array<array-key, Type> $arguments
*/
public function __construct(private Scope $scope, private array $arguments) {}
public function get(string $name, int $position, ?Type $default = new UnknownType): ?Type
{
if ($argumentType = $this->arguments[$name] ?? $this->arguments[$position] ?? null) {
return ReferenceTypeResolver::getInstance()->resolve($this->scope, $argumentType);
}
return $default;
}
public function map(callable $cb): ArgumentTypeBag
{
return new self(
$this->scope,
collect($this->arguments)->map(fn ($t, $key) => $cb($t, $key))->all(),
);
}
public function all(): array
{
return array_map(
fn ($t) => ReferenceTypeResolver::getInstance()->resolve($this->scope, $t),
$this->arguments,
);
}
public function count(): int
{
return count($this->arguments);
}
/** @return array<array-key, Type> */
public function allUnresolved(): array
{
return $this->arguments;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
class ClassLike implements DefinitionMatcher
{
public function __construct(public readonly string $class) {}
public function matches(string $class): bool
{
return $class === $this->class;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
class ClassLikeAndChildren implements DefinitionMatcher
{
public function __construct(public readonly string $class) {}
public function matches(string $class): bool
{
return is_a($class, $this->class, true);
}
}
@@ -0,0 +1,8 @@
<?php
namespace Dedoc\Scramble\Infer\Configuration;
interface DefinitionMatcher
{
public function matches(string $class): bool;
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Dedoc\Scramble\Infer;
use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker;
class Context
{
private static $instance = null;
public function __construct(
public readonly ExtensionsBroker $extensionsBroker,
) {}
public static function configure(
ExtensionsBroker $extensionsBroker,
) {
static::$instance = new static(
$extensionsBroker,
);
}
public static function getInstance(): static
{
if (! static::$instance) {
static::$instance = new static(
app(ExtensionsBroker::class),
);
}
return static::$instance;
}
public static function reset()
{
static::$instance = null;
}
}
@@ -0,0 +1,20 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Countable;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
interface ArgumentTypeBag extends Countable
{
public function get(string $name, int $position, ?Type $default = new UnknownType): ?Type;
/** @return array<array-key, Type> */
public function all(): array;
/**
* @param callable(Type, string|int): Type $cb
*/
public function map(callable $cb): self;
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Definition\ClassDefinition as ClassDefinitionData;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface ClassDefinition
{
public function getMethod(string $name): ?FunctionLikeDefinition;
public function getData(): ClassDefinitionData;
}
@@ -0,0 +1,8 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
interface ClassDefinitionBuilder
{
public function build(): ClassDefinition;
}
@@ -0,0 +1,10 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface FunctionLikeDefinitionBuilder
{
public function build(): FunctionLikeDefinition;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Contracts;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
interface Index
{
public function getFunction(string $name): ?FunctionLikeDefinition;
public function getClass(string $name): ?ClassDefinitionContract;
}
@@ -0,0 +1,32 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use ReflectionAttribute;
class AttributeDefinition
{
public const IS_INSTANCEOF = ReflectionAttribute::IS_INSTANCEOF;
public function __construct(
public string $name,
public array $arguments = [],
) {}
/**
* @param ReflectionAttribute[] $reflectionAttributes
* @return self[]
*/
public static function fromReflectionAttributesArray(array $reflectionAttributes): array
{
return array_map(static::fromReflectionAttribute(...), $reflectionAttributes);
}
public static function fromReflectionAttribute(ReflectionAttribute $reflectionAttribute): self
{
return new self(
name: $reflectionAttribute->getName(),
arguments: $reflectionAttribute->getArguments(),
);
}
}
@@ -0,0 +1,477 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Analyzer\MethodAnalyzer;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeReflectionDefinitionBuilder;
use Dedoc\Scramble\Infer\Reflector\ClassReflector;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\IndexBuilders\IndexBuilder;
use Dedoc\Scramble\Support\PhpDoc;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use PhpParser\ErrorHandler\Throwing;
use PhpParser\NameContext;
use PHPStan\PhpDocParser\Ast\PhpDoc\ExtendsTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\UsesTagValueNode;
class ClassDefinition implements ClassDefinitionContract
{
private bool $propagatesTemplates = false;
/** @var array<string, true> */
protected array $loadedMethods = [];
/** @var Collection<string, Collection<string, Type>> */
private Collection $classContexts;
private Index $index;
public function __construct(
// FQ name
public string $name,
/** @var TemplateType[] $templateTypes */
public array $templateTypes = [],
/** @var array<string, ClassPropertyDefinition> $properties */
public array $properties = [],
/** @var array<string, FunctionLikeDefinition> $methods */
public array $methods = [],
public ?string $parentFqn = null,
) {}
public function propagatesTemplates(?bool $propagatesTemplates = null): bool
{
if ($propagatesTemplates === null) {
return $this->propagatesTemplates;
}
return $this->propagatesTemplates = $propagatesTemplates;
}
public function isInstanceOf(string $className)
{
return is_a($this->name, $className, true);
}
public function isChildOf(string $className)
{
return $this->isInstanceOf($className) && $this->name !== $className;
}
public function hasMethodDefinition(string $name): bool
{
return $this->lazilyLoadMethodDefinition($name) || $this->findReflectionMethod($name);
}
public function getMethodDefinitionWithoutAnalysis(string $name): ?FunctionLikeDefinition
{
return $this->lazilyLoadMethodDefinition($name);
}
protected function lazilyLoadMethodDefinition(string $name): ?FunctionLikeDefinition
{
if (array_key_exists($name, $this->loadedMethods)) {
return $this->methods[$name] ?? null;
}
$this->loadedMethods[$name] = true;
/** @var \ReflectionMethod|null $reflectionMethod */
$reflectionMethod = rescue(
fn () => (new \ReflectionClass($this->name))->getMethod($name), // @phpstan-ignore argument.type
report: false,
);
if (! $reflectionMethod) {
return $this->methods[$name] ?? null;
}
if ($this->isMethodDefinedInNonAstAnalyzableTrait($name)) {
return $this->methods[$name] ?? null;
}
if (
$reflectionMethod->class === $this->name
|| Scramble::infer()->config->shouldAnalyzeAst($reflectionMethod->class)
) {
return $this->methods[$reflectionMethod->name] = new FunctionLikeDefinition(
new FunctionType(
$reflectionMethod->name,
arguments: [],
returnType: new UnknownType,
),
definingClassName: $reflectionMethod->class,
isStatic: $reflectionMethod->isStatic(),
);
}
return $this->methods[$name] ?? null;
}
public function getMethodDefiningClassName(string $name, Index $index)
{
$lastLookedUpClassName = $this->name;
while ($lastLookedUpClassDefinition = $index->getClass($lastLookedUpClassName)) {
if ($methodDefinition = $lastLookedUpClassDefinition->getMethodDefinitionWithoutAnalysis($name)) {
return $methodDefinition->definingClassName;
}
if ($lastLookedUpClassDefinition->parentFqn) {
$lastLookedUpClassName = $lastLookedUpClassDefinition->parentFqn;
continue;
}
break;
}
return $lastLookedUpClassName;
}
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = [], bool $withSideEffects = false): ?FunctionLikeDefinition
{
if (! $methodDefinition = $this->lazilyLoadMethodDefinition($name)) {
return $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
return $this->getFunctionLikeDefinitionBuiltFromAst($methodDefinition, $name, $scope, $indexBuilders, $withSideEffects);
}
protected function isMethodDefinedInNonAstAnalyzableTrait(string $name): bool
{
if (! $reflectionMethod = $this->findReflectionMethod($name)) {
return false;
}
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($this->name), report: false); // @phpstan-ignore argument.type
if (! $classReflection) {
return false;
}
if ($this->isDeclaredIn($reflectionMethod, $classReflection)) {
return false;
}
foreach (class_uses_recursive($classReflection->name) as $traitName) {
$traitReflection = rescue(
fn () => new \ReflectionClass($traitName), // @phpstan-ignore argument.type
report: false,
);
if (! $traitReflection) {
continue;
}
if (! $this->isDeclaredIn($reflectionMethod, $traitReflection)) {
return false;
}
return ! Scramble::infer()->config->shouldAnalyzeAst($traitName);
}
return false;
}
/**
* @param \ReflectionClass<object> $class
*/
private function isDeclaredIn(\ReflectionMethod $reflectionMethod, \ReflectionClass $class): bool
{
$reflectionMethodFileName = $reflectionMethod->getFileName();
$reflectionMethodStartLine = $reflectionMethod->getStartLine();
$classFileName = $class->getFileName();
$classStartLine = $class->getStartLine();
$classEndLine = $class->getEndLine();
if (! $reflectionMethodFileName || ! $classFileName) {
return false;
}
if ($reflectionMethodFileName !== $classFileName) {
return false;
}
return $reflectionMethodStartLine > $classStartLine && $reflectionMethodStartLine < $classEndLine;
}
private function findReflectionMethod(string $name): ?\ReflectionMethod
{
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($this->name), report: false); // @phpstan-ignore argument.type
/** @var \ReflectionMethod|null $methodReflection */
$methodReflection = rescue(fn () => $classReflection?->getMethod($name), report: false);
// The case when method is defined in the class or its parents.
if ($methodReflection && $this->isDeclaredIn($methodReflection, $methodReflection->getDeclaringClass())) {
return $methodReflection;
}
foreach ($this->getClassContexts()->keys() as $class) {
/** @var \ReflectionClass<object>|null $classReflection */
$classReflection = rescue(fn () => new \ReflectionClass($class), report: false); // @phpstan-ignore argument.type
/** @var \ReflectionMethod|null $traitMethodReflection */
$traitMethodReflection = rescue(fn () => $classReflection?->getMethod($name), report: false);
if ($traitMethodReflection) {
return $traitMethodReflection;
}
}
return $methodReflection;
}
protected function getFunctionLikeDefinitionBuiltFromReflection(string $name): ?FunctionLikeDefinition
{
if (array_key_exists($name, $this->methods)) {
return $this->methods[$name];
}
if (! $methodReflection = $this->findReflectionMethod($name)) {
return null;
}
$definition = (new FunctionLikeReflectionDefinitionBuilder(
$name,
$methodReflection,
collect($this->templateTypes)->keyBy->name
->merge($this->getMethodContextTemplates($methodReflection)),
))->build();
return $this->methods[$name] = $definition;
}
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
protected function getFunctionLikeDefinitionBuiltFromAst(
FunctionLikeDefinition $methodDefinition,
string $name,
Scope $scope = new GlobalScope,
array $indexBuilders = [],
bool $withSideEffects = false,
): ?FunctionLikeDefinition {
if (! $methodDefinition->isFullyAnalyzed()) {
$this->methods[$name] = (new MethodAnalyzer(
$scope->index,
$this,
))->analyze($methodDefinition, $indexBuilders, $withSideEffects);
}
if (! $this->methods[$name]) { // @phpstan-ignore booleanNot.alwaysFalse
return $this->methods[$name];
}
if (! $this->methods[$name]->referencesResolved) {
$methodScope = new Scope(
$scope->index,
new NodeTypesResolver,
new ScopeContext($this, $methodDefinition),
new FileNameResolver(
class_exists($this->name)
? ClassReflector::make($this->name)->getNameContext()
: tap(new NameContext(new Throwing), fn (NameContext $nc) => $nc->startNamespace()),
),
);
FunctionLikeAstDefinitionBuilder::resolveFunctionParameterDefaults($methodScope, $this->methods[$name]);
FunctionLikeAstDefinitionBuilder::resolveFunctionReturnReferences($methodScope, $this->methods[$name]);
FunctionLikeAstDefinitionBuilder::resolveFunctionExceptions($methodScope, $this->methods[$name]);
$this->methods[$name]->referencesResolved = true;
}
return $this->methods[$name];
}
/**
* @return Collection<string, Type>
*/
private function getMethodContextTemplates(\ReflectionMethod $methodReflection): Collection
{
$classContexts = $this->getClassContexts();
return $classContexts->get($methodReflection->class, collect());
}
/**
* @param string[] $ignoreClasses
* @return Collection<string, Collection<string, Type>>
*/
public function getClassContexts(array $ignoreClasses = []): Collection
{
if (isset($this->classContexts)) {
return $this->classContexts;
}
if (in_array($this->name, $ignoreClasses, true)) {
return collect();
}
$reflector = ClassReflector::make($this->name);
$docComment = rescue(fn () => $reflector->getReflection()->getDocComment(), report: false) ?: '';
$classSource = $docComment."\n".rescue($reflector->getSource(...), '', report: false);
/** @var Collection<int, string> $tagsDoc */
$tagsDoc = Str::matchAll('/@(?:use|extends|mixin)\s+[^\r\n*]+/', $classSource);
$contextPhpDoc = $tagsDoc->map(fn ($s) => " * $s")->prepend('/**')->push('*/')->join("\n");
$nameContext = rescue($reflector->getNameContext(...), report: false);
$phpDoc = PhpDoc::parse(
$contextPhpDoc,
$nameContext ? new FileNameResolver($nameContext) : null,
);
/** @var (ExtendsTagValueNode|UsesTagValueNode|MixinTagValueNode)[] $tags */
$tags = [
...array_values($phpDoc->getExtendsTagValues()),
...array_values($phpDoc->getUsesTagValues()),
...array_values($phpDoc->getMixinTagValues()),
];
$classTemplatesByName = collect($this->templateTypes)->keyBy->name;
$types = collect($tags)
->map(fn ($tag) => PhpDocTypeHelper::toType($tag->type))
->filter(fn ($type) => $type instanceof ObjectType);
if ($this->parentFqn && ! $types->firstWhere('name', $this->parentFqn)) {
$types->push(new ObjectType($this->parentFqn));
}
$classContexts = collect();
foreach ($types as $type) {
$classContext = collect();
$type = ! $type instanceof Generic ? new Generic($type->name) : $type;
if ($definition = $this->getIndex()->getClass($type->name)) {
foreach ($definition->templateTypes as $i => $templateType) {
$concreteType = (new TypeWalker)->map(
$type->templateTypes[$i]
?? $this->templateTypes[$i]
?? $templateType->default
?? new UnknownType,
fn ($t) => $t instanceof ObjectType ? $classTemplatesByName->get($t->name, $t) : $t,
);
$classContext->offsetSet($templateType->name, $concreteType);
}
}
$classContexts->offsetSet($type->name, $classContext);
}
foreach ($classContexts as $class => $localContext) {
if (in_array($class, $ignoreClasses, true)) {
continue;
}
if (! $classDef = $this->getIndex()->getClass($class)) {
continue;
}
$classContext = $classDef->getClassContexts([...$ignoreClasses, $this->name]);
$localizedClassContext = $classContext->map->map(function ($type) use ($localContext) {
return (new TypeWalker)->map(
$type,
fn ($t) => $type instanceof TemplateType
? $localContext->get($type->name, new UnknownType)
: $type,
);
});
$classContexts = $localizedClassContext->merge($classContexts);
}
// $this->dumpContext($this, $classContexts);
return $this->classContexts = $classContexts;
}
/**
* @param Collection<string, Collection<string, Type>> $classContexts
*/
private function dumpContext(ClassDefinition $def, Collection $classContexts): void // @phpstan-ignore method.unused
{
$className = $def->name.($def->templateTypes ? '<'.implode(',', array_map($this->dumpTemplateName(...), $def->templateTypes)).'>' : '');
$contextNames = $classContexts->map(function ($contexts, $name) {
$templates = $contexts->map(fn ($t) => $t instanceof TemplateType ? $this->dumpTemplateName($t) : $t->toString());
return $name.($templates->count() ? '<'.$templates->join(',').'>' : '');
})->values()->toArray();
dump([
$className => $contextNames,
]);
}
private function dumpTemplateName(TemplateType $tt): string
{
return $tt->name.'#'.spl_object_id($tt);
}
public function setIndex(Index $index): void
{
$this->index = $index;
}
protected function getIndex(): Index
{
return isset($this->index) ? $this->index : app(Index::class);
}
public function getPropertyDefinition($name)
{
return $this->properties[$name] ?? null;
}
public function hasPropertyDefinition(string $name): bool
{
return array_key_exists($name, $this->properties);
}
public function getMethodCallType(string $name)
{
return $this->getMethodDefinition($name)?->getReturnType()
?: new UnknownType("Cannot get type of calling method [$name] on object [$this->name]");
}
public function getMethod(string $name): ?FunctionLikeDefinition
{
return $this->getMethodDefinition($name);
}
public function getData(): ClassDefinition
{
return $this;
}
}
@@ -0,0 +1,51 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Support\Type\Type;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class ClassPropertyDefinition
{
private ?PhpDocNode $docNode = null;
public function __construct(
public Type $type,
public ?Type $defaultType = null,
public readonly bool $isStatic = false,
public readonly PropertyVisibility $visibility = PropertyVisibility::Public,
/** @var AttributeDefinition[] */
public readonly array $attributes = [],
private readonly ?PendingDocComment $pendingDocComment = null,
) {}
public function getDocNode(): ?PhpDocNode
{
if (! $this->pendingDocComment) {
return null;
}
return $this->docNode ??= $this->pendingDocComment->resolve();
}
/**
* @return AttributeDefinition[]
*/
public function getAttributes(?string $name = null, int $flags = 0): array
{
if ($name === null) {
return $this->attributes;
}
return array_values(array_filter(
$this->attributes,
function (AttributeDefinition $attribute) use ($name, $flags) {
if ($flags & AttributeDefinition::IS_INSTANCEOF) {
return is_a($attribute->name, $name, true);
}
return $attribute->name === $name;
},
));
}
}
@@ -0,0 +1,129 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Flow\Nodes;
use Dedoc\Scramble\Infer\FlowBuilder;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
use PhpParser\Node\FunctionLike;
use PhpParser\NodeTraverser;
class FunctionLikeAstDefinition extends FunctionLikeDefinition
{
private ?FunctionLikeDefinition $declarationDefinition = null;
private ?Scope $scope = null;
private ?FunctionLike $astNode = null;
private ?Nodes $flowContainer = null;
public function setDeclarationDefinition(?FunctionLikeDefinition $declarationDefinition): self
{
$this->declarationDefinition = $declarationDefinition;
return $this;
}
public function getDeclarationDefinition(): ?FunctionLikeDefinition
{
return $this->declarationDefinition;
}
public function setAstNode(FunctionLike $astNode): self
{
$this->astNode = $astNode;
return $this;
}
public function getAstNode(): FunctionLike
{
if (! $this->astNode) {
/**
* @see \Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder
*/
throw new \LogicException('AST node must be set before accessing it on FunctionLikeAstDefinition');
}
return $this->astNode;
}
public function setScope(Scope $scope): self
{
$this->scope = $scope;
return $this;
}
public function getScope(): Scope
{
if (! $this->scope) {
/**
* @see \Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeAstDefinitionBuilder
*/
throw new \LogicException('Scope must be set before accessing it on FunctionLikeAstDefinition');
}
return $this->scope;
}
public function getFlowContainer(): Nodes
{
if ($this->flowContainer) {
return $this->flowContainer;
}
$traverser = new NodeTraverser;
$traverser->addVisitor($flowBuilder = new FlowBuilder(
$this->type->arguments, // parameters
$this->getScope(),
));
$traverser->traverse([$this->getAstNode()]);
return $this->flowContainer = $flowBuilder->flowNodes;
}
public function getInferredReturnType(): Type
{
return $this->type->getReturnType();
}
public function getReturnType(): Type
{
$inferredReturnType = $this->type->getReturnType();
if ($inferredReturnType->getAttribute('fromScrambleReturn') === true) {
return $inferredReturnType;
}
if (! $returnDeclarationType = $this->getDeclarationDefinition()?->getReturnType()) {
return $inferredReturnType;
}
return $this->prefersInferredReturnType($returnDeclarationType, $inferredReturnType)
? $inferredReturnType
: $returnDeclarationType;
}
private function prefersInferredReturnType(?Type $declarationType, Type $inferredType): bool
{
if (! $declarationType || $declarationType instanceof UnknownType) {
return true;
}
if ($declarationType->accepts($inferredType)) {
return true;
}
if ($inferredType->acceptedBy($declarationType)) {
return true;
}
return false;
}
}
@@ -0,0 +1,61 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\DefinitionBuilders\SelfOutTypeBuilder;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\Type;
class FunctionLikeDefinition
{
public bool $isFullyAnalyzed = false;
public bool $referencesResolved = false;
public ?Generic $selfOutType;
/**
* @param array<string, Type> $argumentsDefaults A map where the key is arg name and value is a default type.
*/
public function __construct(
public FunctionType $type,
public array $argumentsDefaults = [],
public ?string $definingClassName = null,
public bool $isStatic = false,
public ?SelfOutTypeBuilder $selfOutTypeBuilder = null,
) {}
public function isFullyAnalyzed(): bool
{
return $this->isFullyAnalyzed;
}
public function addArgumentDefault(string $paramName, Type $type): self
{
$this->argumentsDefaults[$paramName] = $type;
return $this;
}
public function getSelfOutType(): ?Generic
{
return $this->selfOutType ??= $this->selfOutTypeBuilder?->build();
}
public function getReturnType(): Type
{
return $this->type->getReturnType();
}
/**
* When analyzing parent classes, function like definitions are "copied" from parent class. When function
* like is sourced from AST, we don't want to make a deep clone to save some memory (is the difference really makes sense?)
* as the definition will be re-build to the specifics of a given class. However, other types of fn definitions
* may override this method and do a deeper cloning (or cloning at all!).
*/
public function copyFromParent(): self
{
return $this;
}
}
@@ -0,0 +1,38 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Contracts\ClassDefinition as ClassDefinitionContract;
use Dedoc\Scramble\Infer\Definition\ClassDefinition as ClassDefinitionData;
use Dedoc\Scramble\Infer\DefinitionBuilders\FunctionLikeReflectionDefinitionBuilder;
use ReflectionClass;
use ReflectionException;
class LazyShallowClassDefinition implements ClassDefinitionContract
{
public function __construct(
public ClassDefinitionContract $definition,
) {}
public function getMethod(string $name): ?FunctionLikeDefinition
{
$data = $this->getData();
if (isset($data->methods[$name])) {
return $data->methods[$name];
}
try {
$reflection = (new ReflectionClass($data->name))->getMethod($name);
} catch (ReflectionException) {
return null;
}
return $data->methods[$name] = (new FunctionLikeReflectionDefinitionBuilder($name, $reflection))->build();
}
public function getData(): ClassDefinitionData
{
return $this->definition->getData();
}
}
@@ -0,0 +1,51 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Support\PhpDoc;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class PendingDocComment
{
public function __construct(
public string $docComment,
public ?string $fileName = null,
public ?string $declaringClass = null,
) {}
public function resolve(): PhpDocNode
{
$fileName = $this->resolveFileName();
return PhpDoc::parse(
$this->docComment,
$fileName ? FileNameResolver::createForFile($fileName) : null,
);
}
private function resolveFileName(): ?string
{
if ($this->fileName) {
return $this->fileName;
}
if ($this->declaringClass) {
return $this->getDeclaringClassFile($this->declaringClass);
}
return null;
}
private function getDeclaringClassFile(string $declaringClass): ?string
{
try {
$reflectionClass = new \ReflectionClass($declaringClass);
return $reflectionClass->getFileName() ?: null;
} catch (\Throwable) {
}
return null;
}
}
@@ -0,0 +1,21 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use ReflectionProperty;
enum PropertyVisibility
{
case Private;
case Protected;
case Public;
public static function fromReflectionProperty(ReflectionProperty $property): self
{
return match (true) {
$property->isPrivate() => self::Private,
$property->isProtected() => self::Protected,
default => self::Public,
};
}
}
@@ -0,0 +1,23 @@
<?php
namespace Dedoc\Scramble\Infer\Definition;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Infer\Scope\Scope;
class ShallowClassDefinition extends ClassDefinition
{
protected function lazilyLoadMethodDefinition(string $name): ?FunctionLikeDefinition
{
return $this->methods[$name] ?? null;
}
public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = [], bool $withSideEffects = false): ?FunctionLikeDefinition
{
if ($this->isMethodDefinedInNonAstAnalyzableTrait($name)) {
return $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
return $this->getMethodDefinitionWithoutAnalysis($name) ?: $this->getFunctionLikeDefinitionBuiltFromReflection($name);
}
}
@@ -0,0 +1,376 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeAstDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent;
use Dedoc\Scramble\Infer\Extensions\Event\SideEffectCallEvent;
use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker;
use Dedoc\Scramble\Infer\Handler\IndexBuildingHandler;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Scope\LazyShallowReflectionIndex;
use Dedoc\Scramble\Infer\Scope\NodeTypesResolver;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Scope\ScopeContext;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Infer\Services\ShallowTypeResolver;
use Dedoc\Scramble\Infer\TypeInferer;
use Dedoc\Scramble\Infer\UnresolvableArgumentTypeBag;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\IndexBuilders\IndexBuilder;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Illuminate\Support\Arr;
use LogicException;
use PhpParser\Node;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\New_;
use PhpParser\Node\Expr\NullsafeMethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\NodeTraverser;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
use ReflectionClass;
use ReflectionFunction;
use ReflectionMethod;
class FunctionLikeAstDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private LazyShallowReflectionIndex $shallowIndex;
/**
* @param IndexBuilder<array<string, mixed>>[] $indexBuilders
*/
public function __construct(
public string $name,
public FunctionLike $functionLike,
public Index $index,
public FileNameResolver $fileNameResolver,
public ?ClassDefinition $classDefinition = null,
public array $indexBuilders = [],
public bool $withSideEffects = false,
) {
$this->shallowIndex = app(LazyShallowReflectionIndex::class);
}
public function build(): FunctionLikeAstDefinition
{
$inferrer = $this->traverseAstNode($this->functionLike);
$scope = $inferrer->getFunctionLikeScope($this->functionLike);
if (! $scope) {
throw new LogicException('Scope must be available when building FunctionLikeAstDefinition');
}
$definition = $scope->context->functionDefinition;
if (! $definition instanceof FunctionLikeAstDefinition) {
throw new LogicException('Definition must be an instance of FunctionLikeAstDefinition');
}
$definition
->setAstNode($this->functionLike)
->setScope($scope);
if ($this->functionLike instanceof ClassMethod) {
$definition->selfOutTypeBuilder = new SelfOutTypeBuilder($scope, $this->functionLike);
}
$definition->setDeclarationDefinition($this->buildDeclarationDefinition());
$this->overrideInferredReturnTypeWithManualAnnotation($definition);
if ($this->withSideEffects) {
$this->analyzeSideEffects($definition, $inferrer);
}
$definition->isFullyAnalyzed = true;
return $definition;
}
private function traverseAstNode(Node $node): TypeInferer
{
$traverser = new NodeTraverser;
$traverser->addVisitor($inferrer = new TypeInferer(
$this->index,
$this->fileNameResolver,
new Scope($this->index, new NodeTypesResolver, new ScopeContext($this->classDefinition), $this->fileNameResolver),
Context::getInstance()->extensionsBroker->extensions,
[new IndexBuildingHandler($this->indexBuilders)],
));
$traverser->traverse(Arr::wrap($node));
return $inferrer;
}
private function analyzeSideEffects(FunctionLikeDefinition $methodDefinition, TypeInferer $inferrer): void
{
$fnScope = $inferrer->getFunctionLikeScope($this->functionLike);
if (! $fnScope) {
return;
}
foreach ($fnScope->getMethodCalls() as $methodCall) {
match (true) {
$methodCall instanceof MethodCall || $methodCall instanceof NullsafeMethodCall => $this->analyzeMethodCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof StaticCall => $this->analyzeStaticMethodCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof FuncCall => $this->analyzeFuncCall($methodDefinition, $fnScope, $methodCall),
$methodCall instanceof New_ => null,
default => null,
};
}
}
private function analyzeMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, MethodCall|NullsafeMethodCall $methodCall): void
{
// 1. ensure method call should be handled
/*
* Only explicit method calls are supported. So the following is supported:
* $this->foo()
* But when the expression is in place, we skip analysis:
* $this->{$var}()
*/
$this->applyExceptionsFromMethodCall($methodDefinition, $fnScope, $methodCall);
if (! $methodCall->name instanceof Identifier) {
return;
}
// 2. get called method definition and if not yet analyzed, analyze shallowly (PHPDoc, type hints)
// get shallow method definition (get shallow callee type, get the shallow definition)
$calleeType = (new ShallowTypeResolver($this->shallowIndex))->resolve($fnScope, $fnScope->getType($methodCall->var));
if ($calleeType instanceof TemplateType && $calleeType->is) {
$calleeType = $calleeType->is;
}
if (! $calleeType instanceof ObjectType) {
return;
}
$definition = $this->shallowIndex->getClass($calleeType->name);
if (! $definition) {
return;
}
$shallowMethodDefinition = $definition->getMethod($methodCall->name->name);
if (! $shallowMethodDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $shallowMethodDefinition,
node: $methodCall,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
));
}
private function applyExceptionsFromMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, MethodCall|NullsafeMethodCall $methodCall): void
{
if (! $methodCall->name instanceof Identifier) {
return;
}
$calleeType = $fnScope->getType($methodCall->var);
if (! $calleeType instanceof ObjectType) {
return;
}
$event = new MethodCallEvent(
$calleeType,
$methodCall->name->name,
$fnScope,
new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
$calleeType->name
);
$exceptions = app(ExtensionsBroker::class)->getMethodCallExceptions($event);
if (empty($exceptions)) {
return;
}
$methodDefinition->type->exceptions = array_merge(
$methodDefinition->type->exceptions,
$exceptions,
);
}
private function analyzeStaticMethodCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, StaticCall $methodCall): void
{
if (! $methodCall->name instanceof Identifier) {
return;
}
if (! $methodCall->class instanceof Name) {
return;
}
$class = ReferenceTypeResolver::resolveClassName($fnScope, $methodCall->class->name);
if (! $class) {
return;
}
$definition = $this->shallowIndex->getClass($class);
if (! $definition) {
return;
}
$shallowMethodDefinition = $definition->getMethod($methodCall->name->name);
if (! $shallowMethodDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $shallowMethodDefinition,
node: $methodCall,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($methodCall->args)),
));
}
private function analyzeFuncCall(FunctionLikeDefinition $methodDefinition, Scope $fnScope, FuncCall $call): void
{
$name = $call->name->getAttribute('namespacedName', $call->name);
if (! $name instanceof Name) {
return;
}
$functionDefinition = $this->shallowIndex->getFunction($name);
if (! $functionDefinition) {
return;
}
$this->applySideEffectsFromCall(new SideEffectCallEvent(
definition: $methodDefinition,
calledDefinition: $functionDefinition,
node: $call,
scope: $fnScope,
arguments: new UnresolvableArgumentTypeBag($fnScope->getArgsTypes($call->args)),
));
}
private function applySideEffectsFromCall(SideEffectCallEvent $event): void
{
foreach ($event->calledDefinition->type->exceptions as $exception) {
$event->definition->type->exceptions[] = $exception;
}
Context::getInstance()->extensionsBroker->afterSideEffectCallAnalyzed($event);
}
private function overrideInferredReturnTypeWithManualAnnotation(FunctionLikeDefinition $definition): void
{
if (! $type = $this->getExplicitScrambleReturnType()) {
return;
}
$definition->type->setReturnType($type);
}
protected function buildDeclarationDefinition(): ?FunctionLikeDefinition
{
$definition = (new FunctionLikeDeclarationAstDefinitionBuilder(
$this->functionLike,
$this->classDefinition,
))->build();
$phpDocNode = $this->functionLike->getAttribute('parsedPhpDoc');
if (! $phpDocNode instanceof PhpDocNode) {
return $definition;
}
return (new FunctionLikeDeclarationPhpDocDefinitionBuilder(
$definition,
$phpDocNode,
$this->classDefinition,
))->build();
}
protected function getReflection(): ReflectionFunction|ReflectionMethod|null
{
try {
if ($this->classDefinition) {
return (new ReflectionClass($this->classDefinition->name))->getMethod($this->name); // @phpstan-ignore argument.type
}
return new ReflectionFunction($this->name);
} catch (\ReflectionException) {
}
return null;
}
private function getExplicitScrambleReturnType(): ?Type
{
$phpDoc = $this->functionLike->getAttribute('parsedPhpDoc');
if (! $phpDoc instanceof PhpDocNode) {
return null;
}
if (! $scrambleReturn = Arr::first($phpDoc->getReturnTagValues('@scramble-return'))) {
return null;
}
/** @var ReturnTagValueNode $scrambleReturn */
$type = PhpDocTypeHelper::toType($scrambleReturn->type);
foreach (($this->classDefinition?->templateTypes ?: []) as $template) {
$type = (new TypeWalker)->map($type, fn ($t) => $t instanceof ObjectType && $t->name === $template->name ? $template : $t);
}
$type->setAttribute('fromScrambleReturn', true);
return $type;
}
public static function resolveFunctionExceptions(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$functionType = $functionLikeDefinition->type;
foreach ($functionType->exceptions as $i => $exceptionType) {
$exception = (new ReferenceTypeResolver($scope->index))->resolve($scope, $exceptionType);
if (! $exception instanceof ObjectType) {
continue;
}
$functionType->exceptions[$i] = $exception;
}
}
public static function resolveFunctionReturnReferences(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$functionType = $functionLikeDefinition->type;
$returnType = $functionType->getReturnType();
$resolvedReference = ReferenceTypeResolver::getInstance()->resolve($scope, $returnType);
$functionType->setReturnType($resolvedReference);
}
public static function resolveFunctionParameterDefaults(Scope $scope, FunctionLikeDefinition $functionLikeDefinition): void
{
$referenceTypeResolver = ReferenceTypeResolver::getInstance();
foreach ($functionLikeDefinition->argumentsDefaults as $name => $argumentDefault) {
$functionLikeDefinition->argumentsDefaults[$name] = $referenceTypeResolver->resolve($scope, $argumentDefault);
}
}
}
@@ -0,0 +1,100 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Scope\GlobalScope;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\MixedType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\UnknownType;
use PhpParser\Node;
use PhpParser\Node\FunctionLike;
use PhpParser\Node\Stmt\ClassMethod;
class FunctionLikeDeclarationAstDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
public function __construct(
private FunctionLike $node,
private ?ClassDefinition $classDefinition = null,
) {}
public function build(): FunctionLikeDefinition
{
return new FunctionLikeDefinition(
$this->buildType(),
argumentsDefaults: $this->getArgumentDefaults(),
definingClassName: $this->classDefinition?->name,
isStatic: $this->node instanceof Node\Stmt\ClassMethod ? $this->node->isStatic() : false,
);
}
private function buildType(): FunctionType
{
return new FunctionType(
$this->getName(),
arguments: $this->getArgumentTypes(),
returnType: $this->getReturnType(),
);
}
private function getReturnType(): Type
{
if (! $returnType = $this->node->getReturnType()) {
return new UnknownType;
}
return TypeHelper::createTypeFromTypeNode($returnType);
}
/** @return array<string, Type> */
private function getArgumentTypes(): array
{
return collect($this->node->getParams())
->mapWithKeys(function (Node\Param $param) {
if (! $param->var instanceof Node\Expr\Variable || ! is_string($param->var->name)) {
return [];
}
$type = $param->type
? TypeHelper::createTypeFromTypeNode($param->type)
: new MixedType;
return [$param->var->name => $type];
})
->all();
}
/** @return array<string, Type> */
private function getArgumentDefaults(): array
{
return collect($this->node->getParams())
->mapWithKeys(function (Node\Param $param) {
if (! $param->default) {
return [];
}
if (! $param->var instanceof Node\Expr\Variable || ! is_string($param->var->name)) {
return [];
}
return [
$param->var->name => (new GlobalScope)->getType($param->default),
];
})
->filter()
->all();
}
private function getName(): string
{
if ($this->node instanceof ClassMethod || $this->node instanceof Node\Stmt\Function_) {
return $this->node->name->name;
}
return '{anonymous}';
}
}
@@ -0,0 +1,95 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
class FunctionLikeDeclarationPhpDocDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private PhpDocNode $phpDocNode;
public function __construct(
private FunctionLikeDefinition $definition,
?PhpDocNode $phpDocNode = null,
private ?ClassDefinition $classDefinition = null,
) {
$this->phpDocNode = $phpDocNode ?: new PhpDocNode([]);
}
public function build(): FunctionLikeDefinition
{
$definition = clone $this->definition;
$definition->type = $definition->type->clone();
$phpDoc = $this->phpDocNode;
foreach ($phpDoc->getTemplateTagValues() as $templateTagValue) {
$definition->type->templates[] = new TemplateType(
name: $templateTagValue->name,
);
}
foreach ($phpDoc->getParamTagValues() as $paramTagValue) {
$name = Str::replaceFirst('$ ', '', $paramTagValue->parameterName);
if (! array_key_exists($name, $definition->type->arguments)) {
continue;
}
$definition->type->arguments[$name] = $this->handleStatic(
PhpDocTypeHelper::toType($paramTagValue->type),
$definition->type->templates,
);
}
foreach ($phpDoc->getThrowsTagValues() as $throwsTagValue) {
$definition->type->exceptions[] = $this->handleStatic(
PhpDocTypeHelper::toType($throwsTagValue->type),
$definition->type->templates,
);
}
if ($returnTagValues = array_values($phpDoc->getReturnTagValues())) {
$definition->type->returnType = $this->handleStatic(
PhpDocTypeHelper::toType($returnTagValues[0]->type),
$definition->type->templates,
);
}
return $definition;
}
/**
* @param TemplateType[] $functionTemplates
*/
private function handleStatic(Type $type, array $functionTemplates): Type
{
$classTemplates = collect($this->classDefinition?->templateTypes ?: [])->keyBy->name;
$functionTemplatesByKeys = collect($functionTemplates)->keyBy->name;
return (new TypeWalker)->map($type, function (Type $t) use ($functionTemplatesByKeys, $classTemplates) {
if (! $t instanceof ObjectType) {
return $t;
}
$t->name = ltrim($t->name, '\\');
if ($definedTemplate = $classTemplates->get($t->name)) {
return $definedTemplate;
}
if ($definedFnTemplate = $functionTemplatesByKeys->get($t->name)) {
return $definedFnTemplate;
}
return $t;
});
}
}
@@ -0,0 +1,170 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\FunctionLikeDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\PhpDoc;
use Dedoc\Scramble\Support\Type\FunctionType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\MixedType;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\TypeWalker;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use ReflectionFunction;
use ReflectionMethod;
use ReflectionParameter;
class FunctionLikeReflectionDefinitionBuilder implements FunctionLikeDefinitionBuilder
{
private ReflectionFunction|ReflectionMethod $reflection;
/** @var Collection<string, covariant Type> */
private Collection $classTemplates;
/**
* @param Collection<string, covariant Type>|null $classTemplates
*/
public function __construct(
public string $name,
ReflectionFunction|ReflectionMethod|null $reflection = null,
?Collection $classTemplates = null,
) {
$this->reflection = $reflection ?: new ReflectionFunction($this->name);
$this->classTemplates = $classTemplates ?: collect();
}
public function build(): FunctionLikeDefinition
{
$functionDefinition = $this->buildFunctionDefinitionFromTypeHints();
$this->applyPhpDoc(
$functionDefinition,
PhpDoc::parse($this->reflection->getDocComment() ?: '/** */', FileNameResolver::createForFile($this->reflection->getFileName())),
);
$functionDefinition->isFullyAnalyzed = true;
return $functionDefinition;
}
private function buildFunctionDefinitionFromTypeHints(): FunctionLikeDefinition
{
$parameters = collect($this->reflection->getParameters())
->mapWithKeys(fn (ReflectionParameter $p) => [
$p->name => ($paramType = $p->getType())
? TypeHelper::createTypeFromReflectionType($paramType)
: new MixedType,
])
->all();
$argumentDefaults = collect($this->reflection->getParameters())
->mapWithKeys(fn (ReflectionParameter $p) => [
$p->name => rescue(function () use ($p) {
return TypeHelper::createTypeFromValue($p->getDefaultValue());
}, report: false),
])
->filter()
->all();
$returnType = ($retType = $this->reflection->getReturnType())
? TypeHelper::createTypeFromReflectionType($retType)
: new UnknownType;
$type = new FunctionType($this->name, $parameters, $returnType);
return new FunctionLikeDefinition(
$type,
argumentsDefaults: $argumentDefaults,
definingClassName: $this->reflection instanceof ReflectionMethod ? $this->reflection->class : null,
);
}
private function applyPhpDoc(FunctionLikeDefinition $definition, PhpDocNode $phpDoc): void
{
foreach ($phpDoc->getTemplateTagValues() as $templateTagValue) {
$definition->type->templates[] = new TemplateType(
name: $templateTagValue->name,
);
}
foreach ($phpDoc->getParamTagValues() as $paramTagValue) {
$name = Str::replaceFirst('$', '', $paramTagValue->parameterName);
if (! array_key_exists($name, $definition->type->arguments)) {
continue;
}
$definition->type->arguments[$name] = $this->handleStatic(
PhpDocTypeHelper::toType($paramTagValue->type),
$definition->type->templates,
);
}
foreach ($phpDoc->getThrowsTagValues() as $throwsTagValue) {
$definition->type->exceptions[] = $this->handleStatic(
PhpDocTypeHelper::toType($throwsTagValue->type),
$definition->type->templates,
);
}
if ($returnTagValues = array_values($phpDoc->getReturnTagValues())) {
$definition->type->returnType = $this->handleStatic(
PhpDocTypeHelper::toType($returnTagValues[0]->type),
$definition->type->templates,
);
}
if (method_exists($phpDoc, 'getSelfOutTypeTagValues')) { // @phpstan-ignore function.alreadyNarrowedType
foreach ($phpDoc->getSelfOutTypeTagValues() as $selfOutTypeTagValue) {
$selfOutType = $this->handleStatic(
PhpDocTypeHelper::toType($selfOutTypeTagValue->type),
$definition->type->templates,
);
if (! $selfOutType instanceof Generic) {
continue;
}
$definition->selfOutType = $this->handleSelfOutType($selfOutType);
}
}
}
/**
* @param TemplateType[] $functionTemplates
*/
private function handleStatic(Type $type, array $functionTemplates): Type
{
$functionTemplatesByKeys = collect($functionTemplates)->keyBy->name;
return (new TypeWalker)->map($type, function (Type $t) use ($functionTemplatesByKeys) {
if (! $t instanceof ObjectType) {
return $t;
}
$t->name = ltrim($t->name, '\\');
if ($definedTemplate = $this->classTemplates->get($t->name)) {
return $definedTemplate;
}
if ($definedFnTemplate = $functionTemplatesByKeys->get($t->name)) {
return $definedFnTemplate;
}
return $t;
});
}
private function handleSelfOutType(Generic $type): Generic
{
return new Generic('self', $type->templateTypes);
}
}
@@ -0,0 +1,83 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Contracts\ClassDefinitionBuilder;
use Dedoc\Scramble\Infer\Contracts\Index as IndexContract;
use Dedoc\Scramble\Infer\Definition\AttributeDefinition;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\ClassPropertyDefinition;
use Dedoc\Scramble\Infer\Definition\LazyShallowClassDefinition;
use Dedoc\Scramble\Infer\Definition\PendingDocComment;
use Dedoc\Scramble\Infer\Definition\PropertyVisibility;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\TypeHelper;
use Dedoc\Scramble\Support\Type\UnknownType;
use Illuminate\Support\Str;
use ReflectionClass;
class LazyClassReflectionDefinitionBuilder implements ClassDefinitionBuilder
{
public function __construct(
public IndexContract $index,
public ReflectionClass $reflection,
) {}
public function build(): LazyShallowClassDefinition
{
$parentDefinition = ($parentName = ($this->reflection->getParentClass() ?: null)?->name)
? ($this->index->getClass($parentName)?->getData() ?? new ClassDefinition(name: ''))
: new ClassDefinition(name: '');
$classDefinitionData = new ClassDefinition(
name: $this->reflection->name,
templateTypes: $parentDefinition->templateTypes,
properties: array_map(fn ($pd) => clone $pd, $parentDefinition->properties ?: []),
methods: $parentDefinition->methods ?: [],
parentFqn: $parentName ?? null,
);
/*
* Traits get analyzed by embracing default behavior of PHP reflection: reflection properties and
* reflection methods get copied into the class that uses the trait.
*/
foreach ($this->reflection->getProperties() as $reflectionProperty) {
if ($reflectionProperty->class !== $this->reflection->name) {
continue;
}
if ($reflectionProperty->isStatic()) {
$classDefinitionData->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $reflectionProperty->hasDefaultValue()
? (TypeHelper::createTypeFromValue($reflectionProperty->getDefaultValue()) ?: new UnknownType)
: new UnknownType,
isStatic: $reflectionProperty->isStatic(),
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $this->reflection->name)
: null,
);
} else {
$classDefinitionData->properties[$reflectionProperty->name] = new ClassPropertyDefinition(
type: $t = new TemplateType(
'T'.Str::studly($reflectionProperty->name),
is: $reflectionProperty->hasType() ? TypeHelper::createTypeFromReflectionType($reflectionProperty->getType()) : new UnknownType,
),
defaultType: $reflectionProperty->hasDefaultValue()
? TypeHelper::createTypeFromValue($reflectionProperty->getDefaultValue())
: null,
visibility: PropertyVisibility::fromReflectionProperty($reflectionProperty),
attributes: AttributeDefinition::fromReflectionAttributesArray($reflectionProperty->getAttributes()),
pendingDocComment: ($docComment = $reflectionProperty->getDocComment() ?: null)
? new PendingDocComment($docComment, declaringClass: $this->reflection->name)
: null,
);
$classDefinitionData->templateTypes[] = $t;
}
}
return new LazyShallowClassDefinition($classDefinitionData);
}
}
@@ -0,0 +1,252 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Infer\Services\RecursionGuard;
use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver;
use Dedoc\Scramble\Infer\Services\TemplateTypesSolver;
use Dedoc\Scramble\Infer\UnresolvableArgumentTypeBag;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\Reference\MethodCallReferenceType;
use Dedoc\Scramble\Support\Type\SelfType;
use Dedoc\Scramble\Support\Type\TemplatePlaceholderType;
use Dedoc\Scramble\Support\Type\TemplateType;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\TypeWalker;
use PhpParser\Node;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\NodeFinder;
class SelfOutTypeBuilder
{
public function __construct(
private Scope $scope,
private ClassMethod $node,
) {}
public function build(): ?Generic
{
if (! $classDefinition = $this->scope->context->classDefinition) {
return null;
}
if (! $functionDefinition = $this->scope->context->functionDefinition) {
return null;
}
$expectedTemplatesMap = collect($classDefinition->templateTypes)
->mapWithKeys(fn (TemplateType $t) => [$t->name => null])
->all();
$templateDefiningStatements = (new NodeFinder)->find(
$this->node->stmts ?: [],
fn ($n) => $this->isThisPropertyAssignment($n) // Direct assignments of something on `$this`, like `$this->foo = 42`.
|| ($functionDefinition->type->name === '__construct' && $this->isParentConstructCall($n)) // Calls to `parent::__construct` if is in constructor
|| ($this->isPotentialSetterCall($n) && $this->isSelfTypeOrCallOnSelfType($this->scope->getType($n->var)))// just any method call on $this (self type!)
);
foreach (array_reverse($templateDefiningStatements) as $statement) {
if ($this->isThisPropertyAssignment($statement)) {
$thisPropertiesAssignment = $statement;
$propertyName = $thisPropertiesAssignment->var->name->name; // @phpstan-ignore property.notFound
if (! array_key_exists($propertyName, $classDefinition->properties)) {
continue;
}
// if property name is not template type - skip
$propertyType = $classDefinition->properties[$propertyName]->type;
if (! $propertyType instanceof TemplateType || ! array_key_exists($propertyType->name, $expectedTemplatesMap)) {
continue;
}
// if property's template type is defined - skip
if ($expectedTemplatesMap[$propertyType->name] !== null) {
continue;
}
// if property template type equals to assigned expression template type - skip
$assignedType = $this->scope->getType($thisPropertiesAssignment->expr);
if ($propertyType === $assignedType) {
continue;
}
// define template
$expectedTemplatesMap[$propertyType->name] = $assignedType;
continue;
}
if ($this->isParentConstructCall($statement)) {
$parentConstructCall = $statement;
if (! $classDefinition->parentFqn) {
continue;
}
if (! $parentDefinition = $this->scope->index->getClass($classDefinition->parentFqn)) {
continue;
}
$parentConstructor = $parentDefinition->getMethodDefinition('__construct');
if (! $constructorSelfOutType = $parentConstructor?->getSelfOutType()) {
continue;
}
$parentCallContextTemplates = (new TemplateTypesSolver)->getClassConstructorContextTemplates(
$parentDefinition,
$parentDefinition->getMethodDefinition('__construct'),
$arguments = new UnresolvableArgumentTypeBag($this->scope->getArgsTypes($parentConstructCall->args)),
);
foreach ($constructorSelfOutType->templateTypes as $index => $genericSelfOutTypePart) {
if (! $definedParentTemplateType = ($parentDefinition->templateTypes[$index] ?? null)) {
continue;
}
// if property's template type is defined - skip
if (($expectedTemplatesMap[$definedParentTemplateType->name] ?? null) !== null) {
continue;
}
$concreteSelfOutTypePart = $genericSelfOutTypePart instanceof TemplatePlaceholderType && $parentCallContextTemplates->has($definedParentTemplateType->name)
? $parentCallContextTemplates->get($definedParentTemplateType->name)
: (new TypeWalker)->map(
$genericSelfOutTypePart,
fn ($t) => $t instanceof TemplateType ? $parentCallContextTemplates->get($t->name, $t) : $t,
);
$expectedTemplatesMap[$definedParentTemplateType->name] = $concreteSelfOutTypePart;
}
continue;
}
// Potential setter calls analysis requires reference resolution!
if ($this->isPotentialSetterCall($statement)) {
$potentialSetterCall = $statement;
/*
* When getting statements we made sure that `var` is either `$this`, or a call to
* a method on `$this` so resolving potential setter calls should not trigger entire codebase
* analysis (which would be slow).
*/
$var = ReferenceTypeResolver::getInstance()->resolve(
$this->scope,
$this->scope->getType($potentialSetterCall->var)->clone(),
);
if (! $var instanceof SelfType) {
continue;
}
if (! $methodDefinition = $classDefinition->getMethodDefinition($potentialSetterCall->name->name)) { // @phpstan-ignore property.notFound
continue;
}
if (! $methodSelfOutType = $this->getMethodSelfOutType($methodDefinition)) {
continue;
}
$methodCallContextTemplates = (new TemplateTypesSolver)->getFunctionContextTemplates(
$methodDefinition,
$arguments = new UnresolvableArgumentTypeBag($this->scope->getArgsTypes($potentialSetterCall->args)),
);
foreach ($methodSelfOutType->templateTypes as $index => $genericSelfOutTypePart) {
if ($genericSelfOutTypePart instanceof TemplatePlaceholderType) {
continue;
}
if (! $definedTemplateType = ($classDefinition->templateTypes[$index] ?? null)) {
continue;
}
// if property's template type is defined - skip
if (($expectedTemplatesMap[$definedTemplateType->name] ?? null) !== null) {
continue;
}
$concreteSelfOutTypePart = (new TypeWalker)->map(
$genericSelfOutTypePart,
fn ($t) => $t instanceof TemplateType ? $methodCallContextTemplates->get($t->name, $t) : $t,
);
$expectedTemplatesMap[$definedTemplateType->name] = $concreteSelfOutTypePart;
}
continue;
}
}
return new Generic(
'self',
array_values(array_map(fn ($type) => $type ?: new TemplatePlaceholderType, $expectedTemplatesMap))
);
}
private function getMethodSelfOutType(FunctionLikeDefinition $methodDefinition): ?Generic
{
return RecursionGuard::run(
$methodDefinition,
fn () => $methodDefinition->getSelfOutType(),
fn () => null,
);
}
/**
* @phpstan-assert-if-true Assign $n
*/
private function isThisPropertyAssignment(Node $n): bool
{
return $n instanceof Assign
&& $n->var instanceof PropertyFetch
&& $n->var->var instanceof Variable
&& $n->var->var->name === 'this'
&& $n->var->name instanceof Identifier;
}
/**
* @phpstan-assert-if-true StaticCall $n
*/
private function isParentConstructCall(Node $n): bool
{
return $n instanceof StaticCall
&& $n->class instanceof Name
&& $n->class->name === 'parent'
&& $n->name instanceof Identifier
&& $n->name->name === '__construct';
}
/**
* @phpstan-assert-if-true MethodCall $n
*/
private function isPotentialSetterCall(Node $n): bool
{
return $n instanceof MethodCall
&& $n->name instanceof Identifier;
}
private function isSelfTypeOrCallOnSelfType(Type $t): bool
{
if ($t instanceof SelfType) {
return true;
}
if (! $t instanceof MethodCallReferenceType) {
return false;
}
return $this->isSelfTypeOrCallOnSelfType($t->callee);
}
}
@@ -0,0 +1,69 @@
<?php
namespace Dedoc\Scramble\Infer\DefinitionBuilders;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Infer\Contracts\ClassDefinitionBuilder;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Definition\ShallowClassDefinition;
use Dedoc\Scramble\Infer\Extensions\Event\ClassDefinitionCreatedEvent;
use Dedoc\Scramble\Infer\Scope\Index;
use Dedoc\Scramble\Infer\Services\FileNameResolver;
use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Support\PhpDoc;
use Dedoc\Scramble\Support\Type\TemplateType;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use ReflectionClass;
class ShallowClassReflectionDefinitionBuilder implements ClassDefinitionBuilder
{
/**
* @param ReflectionClass<object> $reflection
*/
public function __construct(
private Index $index,
private ReflectionClass $reflection
) {}
public function build(): ClassDefinition
{
$parentName = ($this->reflection->getParentClass() ?: null)?->name;
$parentDefinition = $parentName ? $this->index->getClass($parentName) : null;
$classPhpDoc = (($comment = $this->reflection->getDocComment()) && ($path = $this->reflection->getFileName()))
? PhpDoc::parse($comment, FileNameResolver::createForFile($path))
: new PhpDocNode([]);
$classTemplates = collect($classPhpDoc->getTemplateTagValues())
->merge($classPhpDoc->getTemplateTagValues('@template-covariant'))
->values()
->map(fn (TemplateTagValueNode $n) => new TemplateType(
name: $n->name,
is: $n->bound ? PhpDocTypeHelper::toType($n->bound) : null,
))
->keyBy('name');
/*
* @todo consider more advanced cloning implementation.
* Currently just cloning property definition feels alright as only its `defaultType` may change.
*/
$classDefinition = new ShallowClassDefinition(
name: $this->reflection->name,
templateTypes: $parentDefinition?->propagatesTemplates()
? array_merge($classTemplates->values()->all(), $parentDefinition->templateTypes ?? [])
: $classTemplates->values()->all(),
properties: array_map(fn ($pd) => clone $pd, $parentDefinition?->properties ?: []),
methods: $parentDefinition?->methods ?: [],
parentFqn: $parentName,
);
$classDefinition->propagatesTemplates($parentDefinition?->propagatesTemplates() ?? false);
$classDefinition->setIndex($this->index);
Context::getInstance()->extensionsBroker->afterClassDefinitionCreated(new ClassDefinitionCreatedEvent($classDefinition->name, $classDefinition));
return $classDefinition;
}
}
@@ -0,0 +1,12 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions;
use Dedoc\Scramble\Infer\Extensions\Event\ClassDefinitionCreatedEvent;
interface AfterClassDefinitionCreatedExtension extends InferExtension
{
public function shouldHandle(string $name): bool;
public function afterClassDefinitionCreated(ClassDefinitionCreatedEvent $event);
}
@@ -0,0 +1,12 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions;
use Dedoc\Scramble\Infer\Extensions\Event\SideEffectCallEvent;
interface AfterSideEffectCallAnalyzed extends InferExtension
{
public function shouldHandle(SideEffectCallEvent $event): bool;
public function afterSideEffectCallAnalyzed(SideEffectCallEvent $event);
}
@@ -0,0 +1,11 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions;
use Dedoc\Scramble\Infer\Extensions\Event\AnyMethodCallEvent;
use Dedoc\Scramble\Support\Type\Type;
interface AnyMethodReturnTypeExtension extends InferExtension
{
public function getMethodReturnType(AnyMethodCallEvent $event): ?Type;
}
@@ -0,0 +1,40 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Infer\Contracts\ArgumentTypeBag;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
use Dedoc\Scramble\Infer\Extensions\Event\Concerns\ArgumentTypesAware;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Type;
class AnyMethodCallEvent
{
use ArgumentTypesAware;
public function __construct(
public readonly Type $instance,
public readonly string $name,
public readonly Scope $scope,
public readonly ArgumentTypeBag $arguments,
public readonly ?string $methodDefiningClassName,
) {}
public function getDefinition(): ?ClassDefinition
{
return $this->instance instanceof ObjectType
? $this->scope->index->getClass($this->instance->name)
: null;
}
public function getInstance(): Type
{
return $this->instance;
}
public function getName(): string
{
return $this->name;
}
}
@@ -0,0 +1,13 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Infer\Definition\ClassDefinition;
class ClassDefinitionCreatedEvent
{
public function __construct(
public readonly string $name,
public readonly ClassDefinition $classDefinition,
) {}
}
@@ -0,0 +1,14 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event\Concerns;
use Dedoc\Scramble\Support\Type\Type;
use Dedoc\Scramble\Support\Type\UnknownType;
trait ArgumentTypesAware
{
public function getArg(string $name, int $position, Type $default = new UnknownType): Type
{
return $this->arguments->get($name, $position, $default) ?: $default;
}
}
@@ -0,0 +1,28 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Infer\Contracts\ArgumentTypeBag;
use Dedoc\Scramble\Infer\Extensions\Event\Concerns\ArgumentTypesAware;
use Dedoc\Scramble\Infer\Scope\Scope;
class FunctionCallEvent
{
use ArgumentTypesAware;
public function __construct(
public readonly string $name,
public readonly Scope $scope,
public readonly ArgumentTypeBag $arguments,
) {}
public function getDefinition()
{
return $this->scope->index->getFunctionDefinition($this->name);
}
public function getName()
{
return $this->name;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Infer\Contracts\ArgumentTypeBag;
use Dedoc\Scramble\Infer\Extensions\Event\Concerns\ArgumentTypesAware;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Support\Type\ObjectType;
class MethodCallEvent
{
use ArgumentTypesAware;
public function __construct(
public readonly ObjectType $instance,
public readonly string $name,
public readonly Scope $scope,
public readonly ArgumentTypeBag $arguments,
public readonly ?string $methodDefiningClassName,
) {}
public function getDefinition()
{
return $this->scope->index->getClass($this->getInstance()->name);
}
public function getInstance()
{
return $this->instance;
}
public function getName()
{
return $this->name;
}
}
@@ -0,0 +1,30 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Infer\Scope\Scope;
use Dedoc\Scramble\Support\Type\ObjectType;
class PropertyFetchEvent
{
public function __construct(
public readonly ObjectType $instance,
public readonly string $name,
public readonly Scope $scope,
) {}
public function getDefinition()
{
return $this->scope->index->getClass($this->getInstance()->name);
}
public function getInstance()
{
return $this->instance;
}
public function getName()
{
return $this->name;
}
}
@@ -0,0 +1,12 @@
<?php
namespace Dedoc\Scramble\Infer\Extensions\Event;
use Dedoc\Scramble\Support\Type\Type;
class ReferenceResolutionEvent
{
public function __construct(
public Type $type,
) {}
}

Some files were not shown because too many files have changed in this diff Show More