1: <?php declare(strict_types = 1);
2:
3: namespace PHPStan\Analyser;
4:
5: use Exception;
6: use JsonSerializable;
7: use Nette\Utils\Strings;
8: use Override;
9: use PhpParser\Node;
10: use PHPStan\ShouldNotHappenException;
11: use ReturnTypeWillChange;
12: use Throwable;
13: use function array_key_exists;
14: use function is_bool;
15: use function sprintf;
16:
17: /**
18: * @api
19: */
20: final class Error implements JsonSerializable
21: {
22:
23: public const PATTERN_IDENTIFIER = '[a-zA-Z0-9](?:[a-zA-Z0-9\\.]*[a-zA-Z0-9])?';
24:
25: /**
26: * Error constructor.
27: *
28: * @param class-string<Node>|null $nodeType
29: * @param mixed[] $metadata
30: * @param array<string, string> $traitContexts
31: */
32: public function __construct(
33: private string $message,
34: private string $file,
35: private ?int $line = null,
36: private bool|Throwable $canBeIgnored = true,
37: private ?string $filePath = null,
38: private ?string $traitFilePath = null,
39: private ?string $tip = null,
40: private ?int $nodeLine = null,
41: private ?string $nodeType = null,
42: private ?string $identifier = null,
43: private array $metadata = [],
44: private ?FixedErrorDiff $fixedErrorDiff = null,
45: private array $traitContexts = [],
46: )
47: {
48: if ($this->identifier !== null && !self::validateIdentifier($this->identifier)) {
49: throw new ShouldNotHappenException(sprintf('Invalid identifier: %s', $this->identifier));
50: }
51: }
52:
53: public function getMessage(): string
54: {
55: return $this->message;
56: }
57:
58: public function getFile(): string
59: {
60: return $this->file;
61: }
62:
63: public function getFilePath(): string
64: {
65: if ($this->filePath === null) {
66: return $this->file;
67: }
68:
69: return $this->filePath;
70: }
71:
72: public function changeFilePath(string $newFilePath): self
73: {
74: if ($this->traitFilePath !== null) {
75: throw new ShouldNotHappenException('Errors in traits not yet supported');
76: }
77:
78: return new self(
79: $this->message,
80: $newFilePath,
81: $this->line,
82: $this->canBeIgnored,
83: $newFilePath,
84: null,
85: $this->tip,
86: $this->nodeLine,
87: $this->nodeType,
88: $this->identifier,
89: $this->metadata,
90: $this->fixedErrorDiff,
91: );
92: }
93:
94: public function changeTraitFilePath(string $newFilePath): self
95: {
96: return new self(
97: $this->message,
98: $this->file,
99: $this->line,
100: $this->canBeIgnored,
101: $this->filePath,
102: $newFilePath,
103: $this->tip,
104: $this->nodeLine,
105: $this->nodeType,
106: $this->identifier,
107: $this->metadata,
108: $this->fixedErrorDiff,
109: $this->traitContexts,
110: );
111: }
112:
113: public function removeTraitContext(): self
114: {
115: if ($this->traitFilePath === null) {
116: throw new ShouldNotHappenException();
117: }
118:
119: return new self(
120: $this->message,
121: $this->traitFilePath,
122: $this->line,
123: $this->canBeIgnored,
124: $this->traitFilePath,
125: $this->traitFilePath,
126: $this->tip,
127: $this->nodeLine,
128: $this->nodeType,
129: $this->identifier,
130: $this->metadata,
131: $this->fixedErrorDiff,
132: $this->traitContexts,
133: );
134: }
135:
136: public function getTraitFilePath(): ?string
137: {
138: return $this->traitFilePath;
139: }
140:
141: /**
142: * Using-class contexts of an error deduplicated directly into the trait
143: * (see ConstantConditionInTraitRule): the file path of each class in whose
144: * context the error was reported => the "trait.php (in context of class X)"
145: * file string it was reported with there. An ignoreErrors path pointing at
146: * one of these files accounts for that class's context only.
147: *
148: * @return array<string, string>
149: */
150: public function getTraitContexts(): array
151: {
152: return $this->traitContexts;
153: }
154:
155: /**
156: * @param array<string, string> $traitContexts
157: */
158: public function withTraitContexts(array $traitContexts): self
159: {
160: return new self(
161: $this->message,
162: $this->file,
163: $this->line,
164: $this->canBeIgnored,
165: $this->filePath,
166: $this->traitFilePath,
167: $this->tip,
168: $this->nodeLine,
169: $this->nodeType,
170: $this->identifier,
171: $this->metadata,
172: $this->fixedErrorDiff,
173: $traitContexts,
174: );
175: }
176:
177: /**
178: * Rebuilds the per-context error this deduplicated trait error was merged
179: * from, for the given using-class file path.
180: */
181: public function asReportedInTraitContext(string $contextFilePath): self
182: {
183: if (!array_key_exists($contextFilePath, $this->traitContexts)) {
184: throw new ShouldNotHappenException(sprintf('Unknown trait context %s', $contextFilePath));
185: }
186:
187: return new self(
188: $this->message,
189: $this->traitContexts[$contextFilePath],
190: $this->line,
191: $this->canBeIgnored,
192: $contextFilePath,
193: $this->traitFilePath,
194: $this->tip,
195: $this->nodeLine,
196: $this->nodeType,
197: $this->identifier,
198: $this->metadata,
199: $this->fixedErrorDiff,
200: );
201: }
202:
203: /**
204: * Rewrites every path this error carries, for portable storage in the result cache. The caller
205: * owns the transformation - see ResultCachePathTransformer, which passes relativizePath() when
206: * storing and absolutizePath() when loading - so both directions apply exactly the same rules
207: * to the error's paths as to the cache's file-path keys.
208: *
209: * @param callable(string): string $transformPath
210: */
211: public function transformPaths(callable $transformPath): self
212: {
213: return new self(
214: $this->message,
215: $transformPath($this->file),
216: $this->line,
217: $this->canBeIgnored,
218: $this->filePath === null ? null : $transformPath($this->filePath),
219: $this->traitFilePath === null ? null : $transformPath($this->traitFilePath),
220: $this->tip,
221: $this->nodeLine,
222: $this->nodeType,
223: $this->identifier,
224: $this->metadata,
225: $this->fixedErrorDiff,
226: $this->transformTraitContexts($transformPath),
227: );
228: }
229:
230: public function getLine(): ?int
231: {
232: return $this->line;
233: }
234:
235: public function canBeIgnored(): bool
236: {
237: return $this->canBeIgnored === true;
238: }
239:
240: public function hasNonIgnorableException(): bool
241: {
242: return $this->canBeIgnored instanceof Throwable;
243: }
244:
245: public function getTip(): ?string
246: {
247: return $this->tip;
248: }
249:
250: public function withoutTip(): self
251: {
252: if ($this->tip === null) {
253: return $this;
254: }
255:
256: return new self(
257: $this->message,
258: $this->file,
259: $this->line,
260: $this->canBeIgnored,
261: $this->filePath,
262: $this->traitFilePath,
263: null,
264: $this->nodeLine,
265: $this->nodeType,
266: $this->identifier,
267: $this->metadata,
268: $this->fixedErrorDiff,
269: $this->traitContexts,
270: );
271: }
272:
273: public function doNotIgnore(): self
274: {
275: if (!$this->canBeIgnored()) {
276: return $this;
277: }
278:
279: return new self(
280: $this->message,
281: $this->file,
282: $this->line,
283: false,
284: $this->filePath,
285: $this->traitFilePath,
286: $this->tip,
287: $this->nodeLine,
288: $this->nodeType,
289: $this->identifier,
290: $this->metadata,
291: $this->fixedErrorDiff,
292: $this->traitContexts,
293: );
294: }
295:
296: public function withIdentifier(string $identifier): self
297: {
298: if ($this->identifier !== null) {
299: throw new ShouldNotHappenException(sprintf('Error already has an identifier: %s', $this->identifier));
300: }
301:
302: return new self(
303: $this->message,
304: $this->file,
305: $this->line,
306: $this->canBeIgnored,
307: $this->filePath,
308: $this->traitFilePath,
309: $this->tip,
310: $this->nodeLine,
311: $this->nodeType,
312: $identifier,
313: $this->metadata,
314: $this->fixedErrorDiff,
315: $this->traitContexts,
316: );
317: }
318:
319: /**
320: * @param mixed[] $metadata
321: */
322: public function withMetadata(array $metadata): self
323: {
324: if ($this->metadata !== []) {
325: throw new ShouldNotHappenException('Error already has metadata');
326: }
327:
328: return new self(
329: $this->message,
330: $this->file,
331: $this->line,
332: $this->canBeIgnored,
333: $this->filePath,
334: $this->traitFilePath,
335: $this->tip,
336: $this->nodeLine,
337: $this->nodeType,
338: $this->identifier,
339: $metadata,
340: $this->fixedErrorDiff,
341: $this->traitContexts,
342: );
343: }
344:
345: public function getNodeLine(): ?int
346: {
347: return $this->nodeLine;
348: }
349:
350: /**
351: * @return class-string<Node>|null
352: */
353: public function getNodeType(): ?string
354: {
355: return $this->nodeType;
356: }
357:
358: /**
359: * Error identifier set via `RuleErrorBuilder::identifier()`.
360: *
361: * List of all current error identifiers in PHPStan: https://phpstan.org/error-identifiers
362: */
363: public function getIdentifier(): ?string
364: {
365: return $this->identifier;
366: }
367:
368: /**
369: * @return mixed[]
370: */
371: public function getMetadata(): array
372: {
373: return $this->metadata;
374: }
375:
376: /**
377: * @internal Experimental
378: */
379: public function getFixedErrorDiff(): ?FixedErrorDiff
380: {
381: return $this->fixedErrorDiff;
382: }
383:
384: /**
385: * @return mixed
386: */
387: #[ReturnTypeWillChange]
388: #[Override]
389: public function jsonSerialize()
390: {
391: $fixedErrorDiffHash = null;
392: $fixedErrorDiffDiff = null;
393: if ($this->fixedErrorDiff !== null) {
394: $fixedErrorDiffHash = $this->fixedErrorDiff->originalHash;
395: $fixedErrorDiffDiff = $this->fixedErrorDiff->diff;
396: }
397:
398: return [
399: 'message' => $this->message,
400: 'file' => $this->file,
401: 'line' => $this->line,
402: 'canBeIgnored' => is_bool($this->canBeIgnored) ? $this->canBeIgnored : 'exception',
403: 'filePath' => $this->filePath,
404: 'traitFilePath' => $this->traitFilePath,
405: 'tip' => $this->tip,
406: 'nodeLine' => $this->nodeLine,
407: 'nodeType' => $this->nodeType,
408: 'identifier' => $this->identifier,
409: 'metadata' => $this->metadata,
410: 'fixedErrorDiffHash' => $fixedErrorDiffHash,
411: 'fixedErrorDiffDiff' => $fixedErrorDiffDiff,
412: 'traitContexts' => $this->traitContexts,
413: ];
414: }
415:
416: /**
417: * @param mixed[] $json
418: */
419: public static function decode(array $json): self
420: {
421: $fixedErrorDiff = null;
422: if ($json['fixedErrorDiffHash'] !== null && $json['fixedErrorDiffDiff'] !== null) {
423: $fixedErrorDiff = new FixedErrorDiff($json['fixedErrorDiffHash'], $json['fixedErrorDiffDiff']);
424: }
425:
426: return new self(
427: $json['message'],
428: $json['file'],
429: $json['line'],
430: $json['canBeIgnored'] === 'exception' ? new Exception() : $json['canBeIgnored'],
431: $json['filePath'],
432: $json['traitFilePath'],
433: $json['tip'],
434: $json['nodeLine'] ?? null,
435: $json['nodeType'] ?? null,
436: $json['identifier'] ?? null,
437: $json['metadata'] ?? [],
438: $fixedErrorDiff,
439: $json['traitContexts'] ?? [],
440: );
441: }
442:
443: /**
444: * @param mixed[] $properties
445: */
446: public static function __set_state(array $properties): self
447: {
448: return new self(
449: $properties['message'],
450: $properties['file'],
451: $properties['line'],
452: $properties['canBeIgnored'],
453: $properties['filePath'],
454: $properties['traitFilePath'],
455: $properties['tip'],
456: $properties['nodeLine'] ?? null,
457: $properties['nodeType'] ?? null,
458: $properties['identifier'] ?? null,
459: $properties['metadata'] ?? [],
460: $properties['fixedErrorDiff'] ?? null,
461: $properties['traitContexts'] ?? [],
462: );
463: }
464:
465: /**
466: * @param callable(string): string $transformPath
467: * @return array<string, string>
468: */
469: private function transformTraitContexts(callable $transformPath): array
470: {
471: $result = [];
472: foreach ($this->traitContexts as $contextFilePath => $contextFile) {
473: $result[$transformPath($contextFilePath)] = $contextFile;
474: }
475:
476: return $result;
477: }
478:
479: public static function validateIdentifier(string $identifier): bool
480: {
481: return Strings::match($identifier, '~^' . self::PATTERN_IDENTIFIER . '$~') !== null;
482: }
483:
484: }
485: