1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\PhpDocParser\Printer;
4:
5: use LogicException;
6: use PHPStan\PhpDocParser\Ast\Attribute;
7: use PHPStan\PhpDocParser\Ast\Comment;
8: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode;
9: use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNode;
10: use PHPStan\PhpDocParser\Ast\Node;
11: use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagMethodValueNode;
12: use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagPropertyValueNode;
13: use PHPStan\PhpDocParser\Ast\PhpDoc\AssertTagValueNode;
14: use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineAnnotation;
15: use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArgument;
16: use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArray;
17: use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArrayItem;
18: use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineTagValueNode;
19: use PHPStan\PhpDocParser\Ast\PhpDoc\ExtendsTagValueNode;
20: use PHPStan\PhpDocParser\Ast\PhpDoc\ImplementsTagValueNode;
21: use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode;
22: use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueParameterNode;
23: use PHPStan\PhpDocParser\Ast\PhpDoc\MixinTagValueNode;
24: use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode;
25: use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode;
26: use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode;
27: use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode;
28: use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
29: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode;
30: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
31: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
32: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode;
33: use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
34: use PHPStan\PhpDocParser\Ast\PhpDoc\PropertyTagValueNode;
35: use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode;
36: use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessParameterIsPassedTagValueNode;
37: use PHPStan\PhpDocParser\Ast\PhpDoc\RequireExtendsTagValueNode;
38: use PHPStan\PhpDocParser\Ast\PhpDoc\RequireImplementsTagValueNode;
39: use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode;
40: use PHPStan\PhpDocParser\Ast\PhpDoc\SealedTagValueNode;
41: use PHPStan\PhpDocParser\Ast\PhpDoc\SelfOutTagValueNode;
42: use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
43: use PHPStan\PhpDocParser\Ast\PhpDoc\ThrowsTagValueNode;
44: use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasImportTagValueNode;
45: use PHPStan\PhpDocParser\Ast\PhpDoc\TypeAliasTagValueNode;
46: use PHPStan\PhpDocParser\Ast\PhpDoc\UsesTagValueNode;
47: use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
48: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode;
49: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode;
50: use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode;
51: use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode;
52: use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode;
53: use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode;
54: use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode;
55: use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode;
56: use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode;
57: use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
58: use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
59: use PHPStan\PhpDocParser\Ast\Type\IntersectionTypeNode;
60: use PHPStan\PhpDocParser\Ast\Type\InvalidTypeNode;
61: use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
62: use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode;
63: use PHPStan\PhpDocParser\Ast\Type\ObjectShapeNode;
64: use PHPStan\PhpDocParser\Ast\Type\OffsetAccessTypeNode;
65: use PHPStan\PhpDocParser\Ast\Type\ThisTypeNode;
66: use PHPStan\PhpDocParser\Ast\Type\TypeNode;
67: use PHPStan\PhpDocParser\Ast\Type\UnionTypeNode;
68: use PHPStan\PhpDocParser\Lexer\Lexer;
69: use PHPStan\PhpDocParser\Parser\TokenIterator;
70: use function array_keys;
71: use function array_map;
72: use function array_values;
73: use function assert;
74: use function count;
75: use function get_class;
76: use function get_object_vars;
77: use function implode;
78: use function in_array;
79: use function is_array;
80: use function preg_match;
81: use function preg_match_all;
82: use function preg_replace_callback;
83: use function sprintf;
84: use function str_replace;
85: use function strlen;
86: use function strpos;
87: use function substr;
88: use function trim;
89: use const PREG_SET_ORDER;
90:
91: /**
92: * Inspired by https://github.com/nikic/PHP-Parser/tree/36a6dcd04e7b0285e8f0868f44bd4927802f7df1
93: *
94: * Copyright (c) 2011, Nikita Popov
95: * All rights reserved.
96: */
97: final class Printer
98: {
99:
100: /** @var Differ<Node> */
101: private Differ $differ;
102:
103: /**
104: * Newline, indentation and asterisk at the start of a PHPDoc line.
105: * Written after each newline in a printed text or description.
106: */
107: private string $lineStart = "\n *";
108:
109: /**
110: * Map From "{$class}->{$subNode}" to string that should be inserted
111: * between elements of this list subnode
112: *
113: * @var array<string, string>
114: */
115: private array $listInsertionMap = [
116: PhpDocNode::class . '->children' => "\n * ",
117: UnionTypeNode::class . '->types' => '|',
118: IntersectionTypeNode::class . '->types' => '&',
119: ArrayShapeNode::class . '->items' => ', ',
120: ObjectShapeNode::class . '->items' => ', ',
121: CallableTypeNode::class . '->parameters' => ', ',
122: CallableTypeNode::class . '->templateTypes' => ', ',
123: GenericTypeNode::class . '->genericTypes' => ', ',
124: ConstExprArrayNode::class . '->items' => ', ',
125: MethodTagValueNode::class . '->parameters' => ', ',
126: DoctrineArray::class . '->items' => ', ',
127: DoctrineAnnotation::class . '->arguments' => ', ',
128: ];
129:
130: /**
131: * [$find, $extraLeft, $extraRight]
132: *
133: * @var array<string, array{string|null, string, string}>
134: */
135: private array $emptyListInsertionMap = [
136: CallableTypeNode::class . '->parameters' => ['(', '', ''],
137: ArrayShapeNode::class . '->items' => ['{', '', ''],
138: ObjectShapeNode::class . '->items' => ['{', '', ''],
139: DoctrineArray::class . '->items' => ['{', '', ''],
140: DoctrineAnnotation::class . '->arguments' => ['(', '', ''],
141: ];
142:
143: /** @var array<string, list<class-string<TypeNode>>> */
144: private array $parenthesesMap = [
145: CallableTypeNode::class . '->returnType' => [
146: CallableTypeNode::class,
147: UnionTypeNode::class,
148: IntersectionTypeNode::class,
149: ],
150: ArrayTypeNode::class . '->type' => [
151: CallableTypeNode::class,
152: UnionTypeNode::class,
153: IntersectionTypeNode::class,
154: ConstTypeNode::class,
155: NullableTypeNode::class,
156: ],
157: OffsetAccessTypeNode::class . '->type' => [
158: CallableTypeNode::class,
159: UnionTypeNode::class,
160: IntersectionTypeNode::class,
161: NullableTypeNode::class,
162: ],
163: NullableTypeNode::class . '->type' => [
164: UnionTypeNode::class,
165: IntersectionTypeNode::class,
166: NullableTypeNode::class,
167: ],
168: ConditionalTypeNode::class . '->subjectType' => [
169: UnionTypeNode::class,
170: IntersectionTypeNode::class,
171: NullableTypeNode::class,
172: ],
173: ];
174:
175: /** @var array<string, list<class-string<TypeNode>>> */
176: private array $parenthesesListMap = [
177: IntersectionTypeNode::class . '->types' => [
178: IntersectionTypeNode::class,
179: UnionTypeNode::class,
180: NullableTypeNode::class,
181: ],
182: UnionTypeNode::class . '->types' => [
183: IntersectionTypeNode::class,
184: UnionTypeNode::class,
185: NullableTypeNode::class,
186: ],
187: ];
188:
189: public function printFormatPreserving(PhpDocNode $node, PhpDocNode $originalNode, TokenIterator $originalTokens): string
190: {
191: $lineStart = $this->lineStart;
192: $this->lineStart = $this->detectLineStart($originalTokens);
193: try {
194: return $this->printFormatPreservingWithLineStart($node, $originalNode, $originalTokens);
195: } finally {
196: $this->lineStart = $lineStart;
197: }
198: }
199:
200: private function printFormatPreservingWithLineStart(PhpDocNode $node, PhpDocNode $originalNode, TokenIterator $originalTokens): string
201: {
202: $this->differ = new Differ(static function ($a, $b) {
203: if ($a instanceof Node && $b instanceof Node) {
204: return $a === $b->getAttribute(Attribute::ORIGINAL_NODE);
205: }
206:
207: return false;
208: });
209:
210: $tokenIndex = 0;
211: $result = $this->printArrayFormatPreserving(
212: $node->children,
213: $originalNode->children,
214: $originalTokens,
215: $tokenIndex,
216: PhpDocNode::class,
217: 'children',
218: );
219: if ($result !== null) {
220: return $result . $originalTokens->getContentBetween($tokenIndex, $originalTokens->getTokenCount());
221: }
222:
223: return $this->print($node);
224: }
225:
226: public function print(Node $node): string
227: {
228: if ($node instanceof PhpDocNode) {
229: return "/**\n *" . implode("\n *", array_map(
230: function (PhpDocChildNode $child): string {
231: $s = $this->addLineStarts($this->print($child));
232: return $s === '' ? '' : ' ' . $s;
233: },
234: $node->children,
235: )) . "\n */";
236: }
237: if ($node instanceof PhpDocTextNode) {
238: return $node->text;
239: }
240: if ($node instanceof PhpDocTagNode) {
241: if ($node->value instanceof DoctrineTagValueNode) {
242: return $this->print($node->value);
243: }
244:
245: return trim(sprintf('%s %s', $node->name, $this->print($node->value)));
246: }
247: if ($node instanceof PhpDocTagValueNode) {
248: return $this->printTagValue($node);
249: }
250: if ($node instanceof TypeNode) {
251: return $this->printType($node);
252: }
253: if ($node instanceof ConstExprNode) {
254: return $this->printConstExpr($node);
255: }
256: if ($node instanceof MethodTagValueParameterNode) {
257: $type = $node->type !== null ? $this->print($node->type) . ' ' : '';
258: $isReference = $node->isReference ? '&' : '';
259: $isVariadic = $node->isVariadic ? '...' : '';
260: $default = $node->defaultValue !== null ? ' = ' . $this->print($node->defaultValue) : '';
261: return "{$type}{$isReference}{$isVariadic}{$node->parameterName}{$default}";
262: }
263: if ($node instanceof CallableTypeParameterNode) {
264: $type = $this->print($node->type) . ' ';
265: $isReference = $node->isReference ? '&' : '';
266: $isVariadic = $node->isVariadic ? '...' : '';
267: $isOptional = $node->isOptional ? '=' : '';
268: return trim("{$type}{$isReference}{$isVariadic}{$node->parameterName}") . $isOptional;
269: }
270: if ($node instanceof ArrayShapeUnsealedTypeNode) {
271: if ($node->keyType !== null) {
272: return sprintf('<%s, %s>', $this->printType($node->keyType), $this->printType($node->valueType));
273: }
274: return sprintf('<%s>', $this->printType($node->valueType));
275: }
276: if ($node instanceof DoctrineAnnotation) {
277: return (string) $node;
278: }
279: if ($node instanceof DoctrineArgument) {
280: return (string) $node;
281: }
282: if ($node instanceof DoctrineArray) {
283: return (string) $node;
284: }
285: if ($node instanceof DoctrineArrayItem) {
286: return (string) $node;
287: }
288: if ($node instanceof ArrayShapeItemNode) {
289: if ($node->keyName !== null) {
290: return sprintf(
291: '%s%s: %s',
292: $this->print($node->keyName),
293: $node->optional ? '?' : '',
294: $this->printType($node->valueType),
295: );
296: }
297:
298: return $this->printType($node->valueType);
299: }
300: if ($node instanceof ObjectShapeItemNode) {
301: if ($node->keyName !== null) {
302: return sprintf(
303: '%s%s: %s',
304: $this->print($node->keyName),
305: $node->optional ? '?' : '',
306: $this->printType($node->valueType),
307: );
308: }
309:
310: return $this->printType($node->valueType);
311: }
312:
313: throw new LogicException(sprintf('Unknown node type %s', get_class($node)));
314: }
315:
316: private function printTagValue(PhpDocTagValueNode $node): string
317: {
318: // only nodes that contain another node are handled here
319: // the rest falls back on (string) $node
320:
321: if ($node instanceof AssertTagMethodValueNode) {
322: $isNegated = $node->isNegated ? '!' : '';
323: $isEquality = $node->isEquality ? '=' : '';
324: $type = $this->printType($node->type);
325: return trim("{$isNegated}{$isEquality}{$type} {$node->parameter}->{$node->method}() {$node->description}");
326: }
327: if ($node instanceof AssertTagPropertyValueNode) {
328: $isNegated = $node->isNegated ? '!' : '';
329: $isEquality = $node->isEquality ? '=' : '';
330: $type = $this->printType($node->type);
331: return trim("{$isNegated}{$isEquality}{$type} {$node->parameter}->{$node->property} {$node->description}");
332: }
333: if ($node instanceof AssertTagValueNode) {
334: $isNegated = $node->isNegated ? '!' : '';
335: $isEquality = $node->isEquality ? '=' : '';
336: $type = $this->printType($node->type);
337: return trim("{$isNegated}{$isEquality}{$type} {$node->parameter} {$node->description}");
338: }
339: if ($node instanceof ExtendsTagValueNode || $node instanceof ImplementsTagValueNode) {
340: $type = $this->printType($node->type);
341: return trim("{$type} {$node->description}");
342: }
343: if ($node instanceof MethodTagValueNode) {
344: $static = $node->isStatic ? 'static ' : '';
345: $returnType = $node->returnType !== null ? $this->printType($node->returnType) . ' ' : '';
346: $parameters = implode(', ', array_map(fn (MethodTagValueParameterNode $parameter): string => $this->print($parameter), $node->parameters));
347: $description = $node->description !== '' ? " {$node->description}" : '';
348: $templateTypes = count($node->templateTypes) > 0 ? '<' . implode(', ', array_map(fn (TemplateTagValueNode $templateTag): string => $this->print($templateTag), $node->templateTypes)) . '>' : '';
349: return "{$static}{$returnType}{$node->methodName}{$templateTypes}({$parameters}){$description}";
350: }
351: if ($node instanceof MixinTagValueNode) {
352: $type = $this->printType($node->type);
353: return trim("{$type} {$node->description}");
354: }
355: if ($node instanceof RequireExtendsTagValueNode) {
356: $type = $this->printType($node->type);
357: return trim("{$type} {$node->description}");
358: }
359: if ($node instanceof RequireImplementsTagValueNode) {
360: $type = $this->printType($node->type);
361: return trim("{$type} {$node->description}");
362: }
363: if ($node instanceof SealedTagValueNode) {
364: $type = $this->printType($node->type);
365: return trim("{$type} {$node->description}");
366: }
367: if ($node instanceof ParamOutTagValueNode) {
368: $type = $this->printType($node->type);
369: return trim("{$type} {$node->parameterName} {$node->description}");
370: }
371: if ($node instanceof ParamTagValueNode) {
372: $reference = $node->isReference ? '&' : '';
373: $variadic = $node->isVariadic ? '...' : '';
374: $type = $this->printType($node->type);
375: return trim("{$type} {$reference}{$variadic}{$node->parameterName} {$node->description}");
376: }
377: if ($node instanceof ParamImmediatelyInvokedCallableTagValueNode) {
378: return trim("{$node->parameterName} {$node->description}");
379: }
380: if ($node instanceof ParamLaterInvokedCallableTagValueNode) {
381: return trim("{$node->parameterName} {$node->description}");
382: }
383: if ($node instanceof ParamClosureThisTagValueNode) {
384: return trim("{$node->type} {$node->parameterName} {$node->description}");
385: }
386: if ($node instanceof PureUnlessCallableIsImpureTagValueNode) {
387: return trim("{$node->parameterName} {$node->description}");
388: }
389: if ($node instanceof PureUnlessParameterIsPassedTagValueNode) {
390: return trim("{$node->parameterName} {$node->description}");
391: }
392: if ($node instanceof PropertyTagValueNode) {
393: $type = $this->printType($node->type);
394: return trim("{$type} {$node->propertyName} {$node->description}");
395: }
396: if ($node instanceof ReturnTagValueNode) {
397: $type = $this->printType($node->type);
398: return trim("{$type} {$node->description}");
399: }
400: if ($node instanceof SelfOutTagValueNode) {
401: $type = $this->printType($node->type);
402: return trim($type . ' ' . $node->description);
403: }
404: if ($node instanceof TemplateTagValueNode) {
405: $upperBound = $node->bound !== null ? ' of ' . $this->printType($node->bound) : '';
406: $lowerBound = $node->lowerBound !== null ? ' super ' . $this->printType($node->lowerBound) : '';
407: $default = $node->default !== null ? ' = ' . $this->printType($node->default) : '';
408: return trim("{$node->name}{$upperBound}{$lowerBound}{$default} {$node->description}");
409: }
410: if ($node instanceof ThrowsTagValueNode) {
411: $type = $this->printType($node->type);
412: return trim("{$type} {$node->description}");
413: }
414: if ($node instanceof TypeAliasImportTagValueNode) {
415: return trim(
416: "{$node->importedAlias} from " . $this->printType($node->importedFrom)
417: . ($node->importedAs !== null ? " as {$node->importedAs}" : ''),
418: );
419: }
420: if ($node instanceof TypeAliasTagValueNode) {
421: $type = $this->printType($node->type);
422: return trim("{$node->alias} {$type}");
423: }
424: if ($node instanceof UsesTagValueNode) {
425: $type = $this->printType($node->type);
426: return trim("{$type} {$node->description}");
427: }
428: if ($node instanceof VarTagValueNode) {
429: $type = $this->printType($node->type);
430: return trim("{$type} " . trim("{$node->variableName} {$node->description}"));
431: }
432:
433: return (string) $node;
434: }
435:
436: private function printType(TypeNode $node): string
437: {
438: if ($node instanceof ArrayShapeNode) {
439: $items = array_map(fn (ArrayShapeItemNode $item): string => $this->print($item), $node->items);
440:
441: if (! $node->sealed) {
442: $items[] = '...' . ($node->unsealedType === null ? '' : $this->print($node->unsealedType));
443: }
444:
445: return $node->kind . '{' . implode(', ', $items) . '}';
446: }
447: if ($node instanceof ArrayTypeNode) {
448: return $this->printOffsetAccessType($node->type) . '[]';
449: }
450: if ($node instanceof CallableTypeNode) {
451: if ($node->returnType instanceof CallableTypeNode || $node->returnType instanceof UnionTypeNode || $node->returnType instanceof IntersectionTypeNode) {
452: $returnType = $this->wrapInParentheses($node->returnType);
453: } else {
454: $returnType = $this->printType($node->returnType);
455: }
456: $template = $node->templateTypes !== []
457: ? '<' . implode(', ', array_map(fn (TemplateTagValueNode $templateNode): string => $this->print($templateNode), $node->templateTypes)) . '>'
458: : '';
459: $parameters = implode(', ', array_map(fn (CallableTypeParameterNode $parameterNode): string => $this->print($parameterNode), $node->parameters));
460: return "{$node->identifier}{$template}({$parameters}): {$returnType}";
461: }
462: if ($node instanceof ConditionalTypeForParameterNode) {
463: return sprintf(
464: '(%s %s %s ? %s : %s)',
465: $node->parameterName,
466: $node->negated ? 'is not' : 'is',
467: $this->printType($node->targetType),
468: $this->printType($node->if),
469: $this->printType($node->else),
470: );
471: }
472: if ($node instanceof ConditionalTypeNode) {
473: return sprintf(
474: '(%s %s %s ? %s : %s)',
475: $this->printConditionalSubjectType($node->subjectType),
476: $node->negated ? 'is not' : 'is',
477: $this->printType($node->targetType),
478: $this->printType($node->if),
479: $this->printType($node->else),
480: );
481: }
482: if ($node instanceof ConstTypeNode) {
483: return $this->printConstExpr($node->constExpr);
484: }
485: if ($node instanceof GenericTypeNode) {
486: $genericTypes = [];
487:
488: foreach ($node->genericTypes as $index => $type) {
489: $variance = $node->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT;
490: if ($variance === GenericTypeNode::VARIANCE_INVARIANT) {
491: $genericTypes[] = $this->printType($type);
492: } elseif ($variance === GenericTypeNode::VARIANCE_BIVARIANT) {
493: $genericTypes[] = '*';
494: } else {
495: $genericTypes[] = sprintf('%s %s', $variance, $this->print($type));
496: }
497: }
498:
499: return $node->type . '<' . implode(', ', $genericTypes) . '>';
500: }
501: if ($node instanceof IdentifierTypeNode) {
502: return $node->name;
503: }
504: if ($node instanceof IntersectionTypeNode || $node instanceof UnionTypeNode) {
505: $items = [];
506: foreach ($node->types as $type) {
507: if (
508: $type instanceof IntersectionTypeNode
509: || $type instanceof UnionTypeNode
510: || $type instanceof NullableTypeNode
511: ) {
512: $items[] = $this->wrapInParentheses($type);
513: continue;
514: }
515:
516: $items[] = $this->printType($type);
517: }
518:
519: return implode($node instanceof IntersectionTypeNode ? '&' : '|', $items);
520: }
521: if ($node instanceof InvalidTypeNode) {
522: return (string) $node;
523: }
524: if ($node instanceof NullableTypeNode) {
525: if (
526: $node->type instanceof IntersectionTypeNode
527: || $node->type instanceof UnionTypeNode
528: || $node->type instanceof NullableTypeNode
529: ) {
530: return '?(' . $this->printType($node->type) . ')';
531: }
532:
533: return '?' . $this->printType($node->type);
534: }
535: if ($node instanceof ObjectShapeNode) {
536: $items = array_map(fn (ObjectShapeItemNode $item): string => $this->print($item), $node->items);
537:
538: return 'object{' . implode(', ', $items) . '}';
539: }
540: if ($node instanceof OffsetAccessTypeNode) {
541: return $this->printOffsetAccessType($node->type) . '[' . $this->printType($node->offset) . ']';
542: }
543: if ($node instanceof ThisTypeNode) {
544: return (string) $node;
545: }
546:
547: throw new LogicException(sprintf('Unknown node type %s', get_class($node)));
548: }
549:
550: private function wrapInParentheses(TypeNode $node): string
551: {
552: return '(' . $this->printType($node) . ')';
553: }
554:
555: /**
556: * What a conditional type asks about, written so that it is read back as
557: * the very same type.
558: *
559: * "?Foo is Bar ? ... : ..." and "Foo|Bar is Baz ? ... : ..." both read as a
560: * type followed by something the type says nothing about, so a subject of
561: * either kind keeps its parentheses.
562: */
563: private function printConditionalSubjectType(TypeNode $type): string
564: {
565: if (
566: $type instanceof UnionTypeNode
567: || $type instanceof IntersectionTypeNode
568: || $type instanceof NullableTypeNode
569: ) {
570: return $this->wrapInParentheses($type);
571: }
572:
573: return $this->printType($type);
574: }
575:
576: private function printOffsetAccessType(TypeNode $type): string
577: {
578: if (
579: $type instanceof CallableTypeNode
580: || $type instanceof UnionTypeNode
581: || $type instanceof IntersectionTypeNode
582: || $type instanceof NullableTypeNode
583: ) {
584: return $this->wrapInParentheses($type);
585: }
586:
587: return $this->printType($type);
588: }
589:
590: private function printConstExpr(ConstExprNode $node): string
591: {
592: // this is fine - ConstExprNode classes do not contain nodes that need smart printer logic
593: return (string) $node;
594: }
595:
596: /**
597: * @param Node[] $nodes
598: * @param Node[] $originalNodes
599: */
600: private function printArrayFormatPreserving(array $nodes, array $originalNodes, TokenIterator $originalTokens, int &$tokenIndex, string $parentNodeClass, string $subNodeName): ?string
601: {
602: // unset() leaves gaps in the keys, the differ reads the lists by position
603: $nodes = array_values($nodes);
604: $originalNodes = array_values($originalNodes);
605:
606: $diff = $this->differ->diffWithReplacements($originalNodes, $nodes);
607: $mapKey = $parentNodeClass . '->' . $subNodeName;
608: $insertStr = $this->listInsertionMap[$mapKey] ?? null;
609: $result = '';
610: $beforeFirstKeepOrReplace = true;
611: $skipRemovedNode = false;
612: $delayedAdd = [];
613:
614: $insertNewline = false;
615: [$isMultiline, $beforeAsteriskIndent, $afterAsteriskIndent] = $this->isMultiline($tokenIndex, $originalNodes, $originalTokens);
616:
617: if ($insertStr === "\n * ") {
618: $lastChildIndent = $this->detectLastChildIndent($originalNodes, $originalTokens);
619: if ($lastChildIndent !== null) {
620: [$beforeAsteriskIndent, $afterAsteriskIndent] = $lastChildIndent;
621: }
622:
623: $insertStr = sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
624: }
625:
626: foreach ($diff as $i => $diffElem) {
627: $diffType = $diffElem->type;
628: $arrItem = $diffElem->new;
629: $origArrayItem = $diffElem->old;
630: if ($diffType === DiffElem::TYPE_KEEP || $diffType === DiffElem::TYPE_REPLACE) {
631: $beforeFirstKeepOrReplace = false;
632: if (!$arrItem instanceof Node || !$origArrayItem instanceof Node) {
633: return null;
634: }
635:
636: /** @var int $itemStartPos */
637: $itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX);
638:
639: /** @var int $itemEndPos */
640: $itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX);
641:
642: if ($itemStartPos < 0 || $itemEndPos < 0 || $itemStartPos < $tokenIndex) {
643: throw new LogicException();
644: }
645:
646: $comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? [];
647: $origComments = $origArrayItem->getAttribute(Attribute::COMMENTS) ?? [];
648:
649: $commentStartPos = count($origComments) > 0 ? $origComments[0]->startIndex : $itemStartPos;
650: assert($commentStartPos >= 0);
651:
652: if ($skipRemovedNode) {
653: // the separator after the removed first item belongs to it
654: $skipRemovedNode = false;
655: } else {
656: $result .= $originalTokens->getContentBetween($tokenIndex, $itemStartPos);
657: }
658:
659: if (count($delayedAdd) > 0) {
660: foreach ($delayedAdd as $delayedAddNode) {
661: $parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
662: && in_array(get_class($delayedAddNode), $this->parenthesesListMap[$mapKey], true);
663: if ($parenthesesNeeded) {
664: $result .= '(';
665: }
666:
667: if ($insertNewline) {
668: $delayedAddComments = $delayedAddNode->getAttribute(Attribute::COMMENTS) ?? [];
669: if (count($delayedAddComments) > 0) {
670: $result .= $this->printComments($delayedAddComments, $beforeAsteriskIndent, $afterAsteriskIndent);
671: $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
672: }
673: }
674:
675: $result = $this->appendNode($result, $this->printNodeFormatPreserving($delayedAddNode, $originalTokens), $afterAsteriskIndent);
676: if ($parenthesesNeeded) {
677: $result .= ')';
678: }
679:
680: if ($insertNewline) {
681: $result .= $insertStr . sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
682: } else {
683: $result .= $insertStr;
684: }
685: }
686:
687: $delayedAdd = [];
688: }
689:
690: $parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
691: && in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true)
692: && !in_array(get_class($origArrayItem), $this->parenthesesListMap[$mapKey], true);
693: $addParentheses = $parenthesesNeeded && !$originalTokens->hasParentheses($itemStartPos, $itemEndPos);
694: if ($addParentheses) {
695: $result .= '(';
696: }
697:
698: if ($comments !== $origComments) {
699: if (count($comments) > 0) {
700: $result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent);
701: $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
702: }
703: }
704:
705: $result .= $this->printNodeFormatPreserving($arrItem, $originalTokens);
706: if ($addParentheses) {
707: $result .= ')';
708: }
709: $tokenIndex = $itemEndPos + 1;
710:
711: } elseif ($diffType === DiffElem::TYPE_ADD) {
712: if ($insertStr === null) {
713: return null;
714: }
715: if (!$arrItem instanceof Node) {
716: return null;
717: }
718:
719: if ($insertStr === ', ' && $isMultiline || count($arrItem->getAttribute(Attribute::COMMENTS) ?? []) > 0) {
720: $insertStr = ',';
721: $insertNewline = true;
722: }
723:
724: if ($beforeFirstKeepOrReplace) {
725: // Will be inserted at the next "replace" or "keep" element
726: $delayedAdd[] = $arrItem;
727: continue;
728: }
729:
730: /** @var int $itemEndPos */
731: $itemEndPos = $tokenIndex - 1;
732: if ($insertNewline) {
733: $comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? [];
734: $result .= $insertStr;
735: if (count($comments) > 0) {
736: $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
737: $result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent);
738: }
739: $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
740: } else {
741: $result .= $insertStr;
742: }
743:
744: $parenthesesNeeded = isset($this->parenthesesListMap[$mapKey])
745: && in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true);
746: if ($parenthesesNeeded) {
747: $result .= '(';
748: }
749:
750: $result = $this->appendNode($result, $this->printNodeFormatPreserving($arrItem, $originalTokens), $afterAsteriskIndent);
751: if ($parenthesesNeeded) {
752: $result .= ')';
753: }
754:
755: $tokenIndex = $itemEndPos + 1;
756:
757: } elseif ($diffType === DiffElem::TYPE_REMOVE) {
758: if (!$origArrayItem instanceof Node) {
759: return null;
760: }
761:
762: /** @var int $itemStartPos */
763: $itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX);
764:
765: /** @var int $itemEndPos */
766: $itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX);
767: if ($itemStartPos < 0 || $itemEndPos < 0) {
768: throw new LogicException();
769: }
770:
771: if ($i === 0) {
772: // If we're removing from the start, keep the tokens before the node and drop those after it,
773: // instead of the other way around.
774: if ($mapKey === PhpDocNode::class . '->children') {
775: // the line of the removed child is dropped, the next child keeps its own line
776: $originalTokensArray = $originalTokens->getTokens();
777: for ($j = $tokenIndex; $j < $itemStartPos; $j++) {
778: if ($originalTokensArray[$j][Lexer::TYPE_OFFSET] === Lexer::TOKEN_PHPDOC_EOL) {
779: break;
780: }
781: $result .= $originalTokensArray[$j][Lexer::VALUE_OFFSET];
782: }
783: } else {
784: $result .= $originalTokens->getContentBetween($tokenIndex, $itemStartPos);
785: $skipRemovedNode = true;
786: }
787: }
788:
789: $tokenIndex = $itemEndPos + 1;
790: }
791: }
792:
793: if (count($delayedAdd) > 0) {
794: if (!isset($this->emptyListInsertionMap[$mapKey])) {
795: return null;
796: }
797:
798: [$findToken, $extraLeft, $extraRight] = $this->emptyListInsertionMap[$mapKey];
799: if ($findToken !== null) {
800: $originalTokensArray = $originalTokens->getTokens();
801: for (; $tokenIndex < count($originalTokensArray); $tokenIndex++) {
802: $result .= $originalTokensArray[$tokenIndex][Lexer::VALUE_OFFSET];
803: if ($originalTokensArray[$tokenIndex][Lexer::VALUE_OFFSET] !== $findToken) {
804: continue;
805: }
806:
807: $tokenIndex++;
808: break;
809: }
810: }
811: $first = true;
812: $result .= $extraLeft;
813: foreach ($delayedAdd as $delayedAddNode) {
814: if (!$first) {
815: $result .= $insertStr;
816: if ($insertNewline) {
817: $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent);
818: }
819: }
820:
821: $result = $this->appendNode($result, $this->printNodeFormatPreserving($delayedAddNode, $originalTokens), $afterAsteriskIndent);
822: $first = false;
823: }
824: $result .= $extraRight;
825: }
826:
827: return $result;
828: }
829:
830: /**
831: * A new empty line in a docblock is " *", without the space after the asterisk
832: */
833: private function appendNode(string $result, string $printedNode, string $afterAsteriskIndent): string
834: {
835: $lineStart = '*' . $afterAsteriskIndent;
836: if ($printedNode !== '' || $afterAsteriskIndent === '' || substr($result, -strlen($lineStart)) !== $lineStart) {
837: return $result . $printedNode;
838: }
839:
840: return substr($result, 0, -strlen($afterAsteriskIndent));
841: }
842:
843: /**
844: * @param list<Comment> $comments
845: */
846: private function printComments(array $comments, string $beforeAsteriskIndent, string $afterAsteriskIndent): string
847: {
848: $formattedComments = [];
849:
850: foreach ($comments as $comment) {
851: $formattedComments[] = str_replace("\n", "\n" . $beforeAsteriskIndent . '*' . $afterAsteriskIndent, $comment->getReformattedText());
852: }
853:
854: return implode("\n$beforeAsteriskIndent*$afterAsteriskIndent", $formattedComments);
855: }
856:
857: /**
858: * @param array<Node|null> $nodes
859: * @return array{bool, string, string}
860: */
861: private function isMultiline(int $initialIndex, array $nodes, TokenIterator $originalTokens): array
862: {
863: $isMultiline = count($nodes) > 1;
864: $pos = $initialIndex;
865: $allText = '';
866: /** @var Node|null $node */
867: foreach ($nodes as $node) {
868: if (!$node instanceof Node) {
869: continue;
870: }
871:
872: $endPos = $node->getAttribute(Attribute::END_INDEX) + 1;
873: $text = $originalTokens->getContentBetween($pos, $endPos);
874: $allText .= $text;
875: if (strpos($text, "\n") === false) {
876: // We require that a newline is present between *every* item. If the formatting
877: // is inconsistent, with only some items having newlines, we don't consider it
878: // as multiline
879: $isMultiline = false;
880: }
881: $pos = $endPos;
882: }
883:
884: $c = preg_match_all('~\n(?<before>[\\x09\\x20]*)\*(?<after>\\x20*)~', $allText, $matches, PREG_SET_ORDER);
885: if ($c === 0) {
886: return [$isMultiline, ' ', ' '];
887: }
888:
889: $before = '';
890: $after = '';
891: foreach ($matches as $match) {
892: if (strlen($match['before']) > strlen($before)) {
893: $before = $match['before'];
894: }
895: if (strlen($match['after']) <= strlen($after)) {
896: continue;
897: }
898:
899: $after = $match['after'];
900: }
901:
902: $before = strlen($before) === 0 ? ' ' : $before;
903: $after = strlen($after) === 0 ? ' ' : $after;
904:
905: return [$isMultiline, $before, $after];
906: }
907:
908: /**
909: * Indentation before and after the asterisk on the line where the last child of a PHPDoc starts.
910: * Other lines, e.g. the next lines of a multi-line description, can have a different indentation.
911: *
912: * @param Node[] $originalNodes
913: * @return array{string, string}|null
914: */
915: private function detectLastChildIndent(array $originalNodes, TokenIterator $originalTokens): ?array
916: {
917: $tokens = $originalTokens->getTokens();
918: for ($i = count($originalNodes) - 1; $i >= 0; $i--) {
919: $index = $originalNodes[$i]->getAttribute(Attribute::START_INDEX) - 1;
920: $lineStart = '';
921: while (isset($tokens[$index]) && $tokens[$index][Lexer::TYPE_OFFSET] === Lexer::TOKEN_HORIZONTAL_WS) {
922: $lineStart = $tokens[$index][Lexer::VALUE_OFFSET] . $lineStart;
923: $index--;
924: }
925:
926: if (!isset($tokens[$index]) || $tokens[$index][Lexer::TYPE_OFFSET] !== Lexer::TOKEN_PHPDOC_EOL) {
927: continue;
928: }
929:
930: $lineStart = $tokens[$index][Lexer::VALUE_OFFSET] . $lineStart;
931: if (preg_match('~\n(?<before>[\x09\x20]*)\*(?<after>[\x09\x20]*)$~', $lineStart, $match) !== 1) {
932: continue;
933: }
934:
935: return [$match['before'], $match['after']];
936: }
937:
938: return null;
939: }
940:
941: private function printNodeFormatPreserving(Node $node, TokenIterator $originalTokens): string
942: {
943: /** @var Node|null $originalNode */
944: $originalNode = $node->getAttribute(Attribute::ORIGINAL_NODE);
945: if ($originalNode === null) {
946: return $this->printReplacement($node);
947: }
948:
949: $class = get_class($node);
950: if ($class !== get_class($originalNode)) {
951: throw new LogicException();
952: }
953:
954: $startPos = $originalNode->getAttribute(Attribute::START_INDEX);
955: $endPos = $originalNode->getAttribute(Attribute::END_INDEX);
956: if ($startPos < 0 || $endPos < 0) {
957: throw new LogicException();
958: }
959:
960: $result = '';
961: $pos = $startPos;
962: $subNodeNames = array_keys(get_object_vars($node));
963: foreach ($subNodeNames as $subNodeName) {
964: $subNode = $node->$subNodeName;
965: $origSubNode = $originalNode->$subNodeName;
966:
967: if (
968: (!$subNode instanceof Node && $subNode !== null)
969: || (!$origSubNode instanceof Node && $origSubNode !== null)
970: ) {
971: if ($subNode === $origSubNode) {
972: // Unchanged, can reuse old code
973: continue;
974: }
975:
976: if (is_array($subNode) && is_array($origSubNode)) {
977: // Array subnode changed, we might be able to reconstruct it
978: $listResult = $this->printArrayFormatPreserving(
979: $subNode,
980: $origSubNode,
981: $originalTokens,
982: $pos,
983: $class,
984: $subNodeName,
985: );
986:
987: if ($listResult === null) {
988: return $this->printReplacement($node);
989: }
990:
991: $result .= $listResult;
992: continue;
993: }
994:
995: return $this->printReplacement($node);
996: }
997:
998: if ($origSubNode === null) {
999: if ($subNode === null) {
1000: // Both null, nothing to do
1001: continue;
1002: }
1003:
1004: return $this->printReplacement($node);
1005: }
1006:
1007: $subStartPos = $origSubNode->getAttribute(Attribute::START_INDEX);
1008: $subEndPos = $origSubNode->getAttribute(Attribute::END_INDEX);
1009: if ($subStartPos < 0 || $subEndPos < 0) {
1010: throw new LogicException();
1011: }
1012:
1013: if ($subEndPos < $subStartPos) {
1014: // the original sub-node has no tokens, e.g. a Doctrine annotation without arguments
1015: if ($subNode !== null && $this->print($subNode) === $this->print($origSubNode)) {
1016: continue;
1017: }
1018:
1019: return $this->printReplacement($node);
1020: }
1021:
1022: if ($subNode === null) {
1023: return $this->printReplacement($node);
1024: }
1025:
1026: $result .= $originalTokens->getContentBetween($pos, $subStartPos);
1027: $mapKey = get_class($node) . '->' . $subNodeName;
1028: $parenthesesNeeded = isset($this->parenthesesMap[$mapKey])
1029: && in_array(get_class($subNode), $this->parenthesesMap[$mapKey], true);
1030:
1031: if ($subNode->getAttribute(Attribute::ORIGINAL_NODE) !== null) {
1032: $parenthesesNeeded = $parenthesesNeeded
1033: && !in_array(get_class($subNode->getAttribute(Attribute::ORIGINAL_NODE)), $this->parenthesesMap[$mapKey], true);
1034: }
1035:
1036: $addParentheses = $parenthesesNeeded && !$originalTokens->hasParentheses($subStartPos, $subEndPos);
1037: if ($addParentheses) {
1038: $result .= '(';
1039: }
1040:
1041: $result .= $this->printNodeFormatPreserving($subNode, $originalTokens);
1042: if ($addParentheses) {
1043: $result .= ')';
1044: }
1045:
1046: $pos = $subEndPos + 1;
1047: }
1048:
1049: return $result . $originalTokens->getContentBetween($pos, $endPos + 1);
1050: }
1051:
1052: /**
1053: * Prints a node that replaces the tokens of the original node.
1054: *
1055: * The tokens of a Doctrine tag value do not contain the annotation name.
1056: * The parent tag prints the name from its own tokens.
1057: */
1058: private function printReplacement(Node $node): string
1059: {
1060: if (!$node instanceof DoctrineTagValueNode) {
1061: return $this->addLineStarts($this->print($node));
1062: }
1063:
1064: $arguments = '(' . implode(', ', $node->annotation->arguments) . ')';
1065: if ($node->annotation->arguments === []) {
1066: $originalNode = $node->getAttribute(Attribute::ORIGINAL_NODE);
1067: if (
1068: $originalNode instanceof DoctrineTagValueNode
1069: && $originalNode->annotation->getAttribute(Attribute::END_INDEX) < $originalNode->annotation->getAttribute(Attribute::START_INDEX)
1070: ) {
1071: $arguments = '';
1072: }
1073: }
1074:
1075: return $this->addLineStarts(trim($arguments . ' ' . $node->description));
1076: }
1077:
1078: /**
1079: * The parser removes the asterisk at the start of each line of a text or description.
1080: * This puts it back.
1081: */
1082: private function addLineStarts(string $s): string
1083: {
1084: return preg_replace_callback(
1085: '~\r?\n(?=(.?))~',
1086: fn (array $matches): string => $matches[1] === '' || $matches[1] === "\r" || $matches[1] === "\n"
1087: ? $this->lineStart
1088: : $this->lineStart . ' ',
1089: $s,
1090: ) ?? $s;
1091: }
1092:
1093: private function detectLineStart(TokenIterator $originalTokens): string
1094: {
1095: foreach ($originalTokens->getTokens() as $token) {
1096: if ($token[Lexer::TYPE_OFFSET] !== Lexer::TOKEN_PHPDOC_EOL) {
1097: continue;
1098: }
1099:
1100: $asteriskPosition = strpos($token[Lexer::VALUE_OFFSET], '*');
1101: if ($asteriskPosition === false) {
1102: continue;
1103: }
1104:
1105: return substr($token[Lexer::VALUE_OFFSET], 0, $asteriskPosition + 1);
1106: }
1107:
1108: return ($originalTokens->getDetectedNewline() ?? "\n") . ' *';
1109: }
1110:
1111: }
1112: