1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Rules;
4:
5: use PhpParser\Node;
6: use PHPStan\Analyser\Error;
7: use PHPStan\ShouldNotHappenException;
8: use function array_map;
9: use function class_exists;
10: use function count;
11: use function implode;
12: use function is_file;
13: use function sprintf;
14:
15: /**
16: * @api
17: * @template-covariant T of RuleError
18: */
19: final class RuleErrorBuilder
20: {
21:
22: private const TYPE_MESSAGE = 1;
23: private const TYPE_LINE = 2;
24: private const TYPE_FILE = 4;
25: private const TYPE_TIP = 8;
26: private const TYPE_IDENTIFIER = 16;
27: private const TYPE_METADATA = 32;
28: private const TYPE_NON_IGNORABLE = 64;
29: private const TYPE_FIXABLE_NODE = 128;
30: private const TYPE_FILE_DEPENDENCIES = 256;
31:
32: private int $type;
33:
34: /** @var mixed[] */
35: private array $properties;
36:
37: /** @var list<string> */
38: private array $tips = [];
39:
40: private function __construct(string $message)
41: {
42: $this->properties['message'] = $message;
43: $this->type = self::TYPE_MESSAGE;
44: }
45:
46: /**
47: * @return array<int, array{string, array<array{string, string|null, string}>}>
48: */
49: public static function getRuleErrorTypes(): array
50: {
51: return [
52: self::TYPE_MESSAGE => [
53: RuleError::class,
54: [
55: [
56: 'message', // property name
57: 'string', // native type
58: 'string', // PHPDoc type
59: ],
60: ],
61: ],
62: self::TYPE_LINE => [
63: LineRuleError::class,
64: [
65: [
66: 'line',
67: 'int',
68: 'int',
69: ],
70: ],
71: ],
72: self::TYPE_FILE => [
73: FileRuleError::class,
74: [
75: [
76: 'file',
77: 'string',
78: 'string',
79: ],
80: [
81: 'fileDescription',
82: 'string',
83: 'string',
84: ],
85: ],
86: ],
87: self::TYPE_TIP => [
88: TipRuleError::class,
89: [
90: [
91: 'tip',
92: 'string',
93: 'string',
94: ],
95: ],
96: ],
97: self::TYPE_IDENTIFIER => [
98: IdentifierRuleError::class,
99: [
100: [
101: 'identifier',
102: 'string',
103: 'string',
104: ],
105: ],
106: ],
107: self::TYPE_METADATA => [
108: MetadataRuleError::class,
109: [
110: [
111: 'metadata',
112: 'array',
113: 'mixed[]',
114: ],
115: ],
116: ],
117: self::TYPE_NON_IGNORABLE => [
118: NonIgnorableRuleError::class,
119: [],
120: ],
121: self::TYPE_FIXABLE_NODE => [
122: FixableNodeRuleError::class,
123: [
124: [
125: 'originalNode',
126: '\PhpParser\Node',
127: '\PhpParser\Node',
128: ],
129: [
130: 'newNodeCallable',
131: null,
132: 'callable(\PhpParser\Node): \PhpParser\Node',
133: ],
134: ],
135: ],
136: self::TYPE_FILE_DEPENDENCIES => [
137: FileDependenciesRuleError::class,
138: [
139: [
140: 'fileDependencies',
141: 'array',
142: 'list<string>',
143: ],
144: ],
145: ],
146: ];
147: }
148:
149: /**
150: * @return self<RuleError>
151: */
152: public static function message(string $message): self
153: {
154: return new self($message);
155: }
156:
157: /**
158: * @phpstan-this-out self<T&LineRuleError>
159: * @return self<T&LineRuleError>
160: */
161: public function line(int $line): self
162: {
163: $this->properties['line'] = $line;
164: $this->type |= self::TYPE_LINE;
165:
166: return $this;
167: }
168:
169: /**
170: * @phpstan-this-out self<T&FileRuleError>
171: * @return self<T&FileRuleError>
172: */
173: public function file(string $file, ?string $fileDescription = null): self
174: {
175: if (!is_file($file)) {
176: throw new ShouldNotHappenException(sprintf('File %s does not exist.', $file));
177: }
178: $this->properties['file'] = $file;
179: $this->properties['fileDescription'] = $fileDescription ?? $file;
180: $this->type |= self::TYPE_FILE;
181:
182: return $this;
183: }
184:
185: /**
186: * Declares that this error depends on the contents of another file, so that the result cache
187: * re-analyses the file the error is reported in when that file appears, changes or is deleted.
188: * The path must be absolute; it does not have to exist.
189: *
190: * @api
191: * @phpstan-this-out self<T&FileDependenciesRuleError>
192: * @return self<T&FileDependenciesRuleError>
193: */
194: public function fileDependency(string $file): self
195: {
196: /** @var list<string> $fileDependencies */
197: $fileDependencies = $this->properties['fileDependencies'] ?? [];
198: $fileDependencies[] = $file;
199: $this->properties['fileDependencies'] = $fileDependencies;
200: $this->type |= self::TYPE_FILE_DEPENDENCIES;
201:
202: return $this;
203: }
204:
205: /**
206: * @phpstan-this-out self<T&TipRuleError>
207: * @return self<T&TipRuleError>
208: */
209: public function tip(string $tip): self
210: {
211: $this->tips = [$tip];
212: $this->type |= self::TYPE_TIP;
213:
214: return $this;
215: }
216:
217: /**
218: * @phpstan-this-out self<T&TipRuleError>
219: * @return self<T&TipRuleError>
220: */
221: public function addTip(string $tip): self
222: {
223: $this->tips[] = $tip;
224: $this->type |= self::TYPE_TIP;
225:
226: return $this;
227: }
228:
229: /**
230: * @phpstan-this-out self<T&TipRuleError>
231: * @return self<T&TipRuleError>
232: */
233: public function discoveringSymbolsTip(): self
234: {
235: return $this->tip('Learn more at https://phpstan.org/user-guide/discovering-symbols');
236: }
237:
238: /**
239: * @param list<string> $reasons
240: * @phpstan-this-out self<T&TipRuleError>
241: * @return self<T&TipRuleError>
242: */
243: public function acceptsReasonsTip(array $reasons): self
244: {
245: foreach ($reasons as $reason) {
246: $this->addTip($reason);
247: }
248:
249: return $this;
250: }
251:
252: /**
253: * @phpstan-this-out self<T&TipRuleError>
254: * @return self<T&TipRuleError>
255: */
256: public function treatPhpDocTypesAsCertainTip(): self
257: {
258: return $this->tip('Because the type is coming from a PHPDoc, you can turn off this check by setting <fg=cyan>treatPhpDocTypesAsCertain: false</> in your <fg=cyan>%configurationFile%</>.');
259: }
260:
261: /**
262: * @param list<string> $callDescriptions
263: * @phpstan-this-out self<T&TipRuleError>
264: * @return self<T&TipRuleError>
265: */
266: public function possiblyImpureTip(array $callDescriptions): self
267: {
268: foreach ($callDescriptions as $callDescription) {
269: $this->addTip(sprintf('If %s is impure, add <fg=cyan>@phpstan-impure</> PHPDoc tag above its declaration. Learn more: <fg=cyan>https://phpstan.org/blog/remembering-and-forgetting-returned-values</>', $callDescription));
270: }
271:
272: return $this;
273: }
274:
275: /**
276: * Sets an error identifier.
277: *
278: * List of all current error identifiers in PHPStan: https://phpstan.org/error-identifiers
279: *
280: * @phpstan-this-out self<T&IdentifierRuleError>
281: * @return self<T&IdentifierRuleError>
282: */
283: public function identifier(string $identifier): self
284: {
285: if (!Error::validateIdentifier($identifier)) {
286: throw new ShouldNotHappenException(sprintf('Invalid identifier: %s, error identifiers must match /%s/', $identifier, Error::PATTERN_IDENTIFIER));
287: }
288:
289: $this->properties['identifier'] = $identifier;
290: $this->type |= self::TYPE_IDENTIFIER;
291:
292: return $this;
293: }
294:
295: /**
296: * @param mixed[] $metadata
297: * @phpstan-this-out self<T&MetadataRuleError>
298: * @return self<T&MetadataRuleError>
299: */
300: public function metadata(array $metadata): self
301: {
302: $this->properties['metadata'] = $metadata;
303: $this->type |= self::TYPE_METADATA;
304:
305: return $this;
306: }
307:
308: /**
309: * @phpstan-this-out self<T&NonIgnorableRuleError>
310: * @return self<T&NonIgnorableRuleError>
311: */
312: public function nonIgnorable(): self
313: {
314: $this->type |= self::TYPE_NON_IGNORABLE;
315:
316: return $this;
317: }
318:
319: /**
320: * @internal Experimental
321: * @template TNode of Node
322: * @param TNode $node
323: * @param callable(TNode): Node $cb
324: * @phpstan-this-out self<T&FixableNodeRuleError>
325: * @return self<T&FixableNodeRuleError>
326: */
327: public function fixNode(Node $node, callable $cb): self
328: {
329: $this->properties['originalNode'] = $node;
330: $this->properties['newNodeCallable'] = $cb;
331: $this->type |= self::TYPE_FIXABLE_NODE;
332:
333: return $this;
334: }
335:
336: /**
337: * @return T
338: */
339: public function build(): RuleError
340: {
341: /** @var class-string<T> $className */
342: $className = sprintf('PHPStan\\Rules\\RuleErrors\\RuleError%d', $this->type);
343: if (!class_exists($className)) {
344: throw new ShouldNotHappenException(sprintf('Class %s does not exist.', $className));
345: }
346:
347: $ruleError = new $className();
348: foreach ($this->properties as $propertyName => $value) {
349: $ruleError->{$propertyName} = $value;
350: }
351:
352: if (count($this->tips) > 0) {
353: if (count($this->tips) === 1) {
354: $ruleError->tip = $this->tips[0];
355: } else {
356: $ruleError->tip = implode("\n", array_map(static fn (string $tip) => sprintf('• %s', $tip), $this->tips));
357: }
358: }
359:
360: return $ruleError;
361: }
362:
363: }
364: