idrivee2-media-upload/vendor/aws/aws-sdk-php/build/PhpstanShapeFormatter.php
2026-09-18 14:34:43 +00:00

353 lines
13 KiB
PHP

<?php
/**
* Renders an AWS api-2.json input shape into a PHPStan/Psalm-compatible
* unsealed array-shape string for use in `@phpstan-method` annotations.
*
* Why a separate formatter
* ------------------------
* The output of this class is consumed by static-analysis tools (PHPStan,
* Psalm) only. PhpStorm ignores `@phpstan-*` tags entirely, so its parser
* limitations on array-shape parameter types in `@method` declarations are
* not in play here. That lets us emit richer types than the plain `@method`
* form can carry.
*
* Shape contract (per docs/METHOD_ANNOTATIONS_TASK.md)
* ----------------------------------------------------
* - Every emitted shape is an `array{ ..., ... }` (unsealed via trailing
* bare `...`). Unsealed shapes accept extra keys without flagging them,
* which is what we want for SDK input bags.
* - Every member is rendered with a `?` optional sigil regardless of the
* model's `required` list. Runtime enforces required-ness; we'd rather
* have false negatives on missing required keys than false positives on
* dynamically built `$args`.
* - Depth is capped at 3 (top-level $args = depth 1). At cap, emit bare
* `array`.
* - Recursive shape references collapse to bare `array`.
* - Empty-input operations still emit `array{...}` (unsealed empty shape)
* so the paired `@method`/`@phpstan-method` invariant holds. Formatter
* returns null only for unknown or non-structure input shapes.
*
* Type mapping
* ------------
* | api-2.json type | rendered |
* |---------------------------|-----------------------------------------------|
* | structure | nested array{...} or `array` at cap |
* | union (or structure with `union: true`) | nested array{...} or `array` |
* | list of T | list<T> |
* | map keyed by string -> V | array<string, V> |
* | string with enum | 'A'\|'B'\|'C' (sorted, escaped) |
* | string | string |
* | integer/long/short/byte | int |
* | bigInteger | int |
* | float/double | float |
* | bigDecimal | string |
* | boolean | bool |
* | timestamp | int\|string\|\DateTimeInterface |
* | blob | string\|resource\|\Psr\Http\Message\StreamInterface |
* | document | mixed |
* | unknown | array (safe fallback) |
*
* Key escaping
* ------------
* Member names matching ^[A-Za-z_][A-Za-z0-9_]*$ are emitted unquoted.
* Otherwise single-quoted with backslash and single-quote escaped.
*
* @internal
*/
final class PhpstanShapeFormatter
{
/**
* Maximum recursion depth. Top-level `$args` is depth 1, so children are
* depth 2, grandchildren depth 3, and depth 4+ collapses to bare `array`.
*/
public const MAX_DEPTH = 3;
/** Sentinel emitted at depth cap or on a recursive shape cycle. */
private const COLLAPSED = 'array';
/** Unsealed empty shape emitted for empty-input operations. */
private const EMPTY_SHAPE = 'array{...}';
/** @var array<string, mixed> */
private array $shapes;
/**
* Stack of shape names currently being expanded. Used for cycle detection.
*
* @var string[]
*/
private array $visitedStack = [];
/**
* @param array $apiDefinition Decoded api-2.json. Must contain a 'shapes' key.
*/
public function __construct(array $apiDefinition)
{
$this->shapes = $apiDefinition['shapes'] ?? [];
}
/**
* Format the input shape for an operation. Returns the PHPStan/Psalm
* array-shape string for the operation's input, or null only when the
* operation has no addressable input shape (missing from shapes map,
* or not a structure type).
*
* Empty-input operations (modeled as a structure with `"members": {}`)
* return the unsealed empty shape `array{...}`. For services that emit
* PHPStan annotations, this keeps the `@method` / `@phpstan-method`
* pairing invariant. `array{...}` is an unsealed array shape with no
* required keys, so it accepts any `array` at runtime without flagging
* extra keys — exactly the semantics consumers want for an op that takes
* no modeled args but may receive an empty (or sentinel) array.
*
* @param string $inputShapeName Name of the input shape from operations[op].input.shape
* @return string|null PHPStan/Psalm array-shape string, multi-line for nested shapes
*/
public function formatInput(string $inputShapeName): ?string
{
if (!isset($this->shapes[$inputShapeName])) {
return null;
}
$shape = $this->shapes[$inputShapeName];
if (($shape['type'] ?? null) !== 'structure') {
return null;
}
$members = $shape['members'] ?? [];
if (count($members) === 0) {
// Empty input — emit the unsealed empty shape so the paired
// emission invariant in ClientAnnotator holds.
return self::EMPTY_SHAPE;
}
return $this->renderStructure($shape, 1);
}
/**
* Renders a single shape at the given recursion depth.
*
* The depth cap applies only when the target shape is itself a
* structure or union — scalar/list/map types render unconditionally
* because they can't directly cause runaway nesting. Lists and maps
* still bound their inner type via the depth they pass to their
* recursive `renderShape` call.
*/
private function renderShape(string $shapeName, int $depth): string
{
if (!isset($this->shapes[$shapeName])) {
return self::COLLAPSED;
}
$shape = $this->shapes[$shapeName];
$type = $shape['type'] ?? null;
$isContainer = $type === 'structure' || $type === 'union';
if ($isContainer && $depth > self::MAX_DEPTH) {
return self::COLLAPSED;
}
if ($isContainer && in_array($shapeName, $this->visitedStack, true)) {
return self::COLLAPSED;
}
if ($isContainer) {
$this->visitedStack[] = $shapeName;
try {
return $this->dispatchType($shape, $depth);
} finally {
array_pop($this->visitedStack);
}
}
return $this->dispatchType($shape, $depth);
}
/**
* Branches on the api-2.json type and emits the corresponding PHPStan
* type expression.
*/
private function dispatchType(array $shape, int $depth): string
{
$type = $shape['type'] ?? null;
switch ($type) {
case 'structure':
// AWS unions are encoded as structures with `union: true`.
// Emit as a nested all-optional shape; static analyzers
// treat it as "any of these keys may be set" which is
// close enough to a tagged union for our purposes.
return $this->renderStructure($shape, $depth);
case 'union':
// Standalone `union` type (less common). Same treatment.
return $this->renderStructure($shape, $depth);
case 'list':
$member = $shape['member']['shape'] ?? null;
if ($member === null) {
return 'list<mixed>';
}
$inner = $this->renderShape($member, $depth + 1);
return "list<{$inner}>";
case 'map':
$valueShape = $shape['value']['shape'] ?? null;
if ($valueShape === null) {
return 'array<string, mixed>';
}
$inner = $this->renderShape($valueShape, $depth + 1);
return "array<string, {$inner}>";
case 'string':
return $this->renderString($shape);
case 'integer':
case 'long':
case 'short':
case 'byte':
case 'bigInteger':
return 'int';
case 'float':
case 'double':
return 'float';
case 'bigDecimal':
return 'string';
case 'boolean':
return 'bool';
case 'timestamp':
return 'int|string|\\DateTimeInterface';
case 'blob':
return 'string|resource|\\Psr\\Http\\Message\\StreamInterface';
case 'document':
return 'mixed';
default:
return 'array';
}
}
/**
* Renders a structure as `array{ Name?: T, ..., ... }`. Always
* unsealed (trailing bare `...`) and all-optional regardless of the
* model's required list. At depth cap or with no members, returns
* the COLLAPSED sentinel.
*/
private function renderStructure(array $shape, int $depth): string
{
$members = $shape['members'] ?? [];
if (count($members) === 0) {
return self::COLLAPSED;
}
$segments = [];
foreach ($members as $name => $member) {
$memberShape = $member['shape'] ?? null;
$rendered = $memberShape !== null
? $this->renderShape($memberShape, $depth + 1)
: 'mixed';
$key = $this->encodeKey($name);
$segments[] = "{$key}?: {$rendered}";
}
// Unsealed via trailing `...`. PHPStan/Psalm won't flag unknown
// keys, including typos, which AWS then ignores at runtime. This
// is a deliberate choice: sealing would catch typos
// but would false-positive on the `array<string, mixed>` values
// customers routinely build from config, request context, or
// tracing state.
$segments[] = '...';
return $this->compose($segments);
}
/**
* Compose member segments into either a single-line `array{a?: T, b?: U, ...}`
* or a multi-line indented form. Multi-line is chosen when any segment
* itself contains a newline (indicating a nested multi-line shape) or
* when the rendered single-line exceeds a soft 110-char budget.
*
* @param string[] $segments Already-formatted member-or-`...` segments
*/
private function compose(array $segments): string
{
$compact = 'array{' . implode(', ', $segments) . '}';
if (
strpos($compact, "\n") === false
&& strlen($compact) <= 110
) {
return $compact;
}
$innerIndent = ' ';
$lines = [];
foreach ($segments as $segment) {
$lines[] = $innerIndent . $this->reindentNested($segment, $innerIndent);
}
return "array{\n" . implode(",\n", $lines) . ",\n}";
}
/**
* When a nested rendered segment already contains newlines, reindent
* its continuation lines so they align under the current indent.
*/
private function reindentNested(string $segment, string $indent): string
{
if (strpos($segment, "\n") === false) {
return $segment;
}
return str_replace("\n", "\n" . $indent, $segment);
}
/**
* Renders a string-typed shape: literal-string union for enums,
* plain `string` otherwise.
*/
private function renderString(array $shape): string
{
$enum = $shape['enum'] ?? null;
if (!is_array($enum) || count($enum) === 0) {
return 'string';
}
$values = $enum;
sort($values, SORT_STRING);
$literals = array_map(
fn (string $v): string => "'" . $this->escapeStringLiteral($v) . "'",
$values
);
return implode('|', $literals);
}
/**
* Escapes the only two characters that break a single-quoted string
* literal in PHPStan/Psalm: backslash and single-quote.
*/
private function escapeStringLiteral(string $value): string
{
return str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
}
/**
* Emit a member key unquoted if it matches PHP's bare-identifier
* pattern. Otherwise wrap in single quotes and escape.
*/
private function encodeKey(string $name): string
{
if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name) === 1) {
return $name;
}
return "'" . $this->escapeStringLiteral($name) . "'";
}
}