1313use function is_string ;
1414use function sprintf ;
1515
16+ /**
17+ * `IgnoredErrorHelper` may collapse several configured ignores into one
18+ * merged entry, so `message`/`rawMessage`/`identifier` are nullable here.
19+ * It also attaches `realPath` once the configured path is resolved. The
20+ * `messages`/`rawMessages`/`identifiers` keys remain in the inferred shape
21+ * even after expansion + unset (PHPStan does not strip optional keys via
22+ * negative isset on sealed shapes), so the type lists them explicitly here
23+ * — they are never read, only tolerated. `paths` is `array<int<0, max>,
24+ * string>` rather than `list<string>` because `process()` unsets matched
25+ * entries by index, breaking list-ness.
26+ *
27+ * @phpstan-type ExpandedIgnoredErrorData = array{
28+ * message?: string|null,
29+ * rawMessage?: string|null,
30+ * identifier?: string|null,
31+ * messages?: list<string>,
32+ * rawMessages?: list<string>,
33+ * identifiers?: list<string>,
34+ * path?: string,
35+ * paths?: array<int<0, max>, string>,
36+ * count?: int,
37+ * reportUnmatched?: bool,
38+ * realPath?: string,
39+ * }
40+ */
1641final class IgnoredErrorHelperResult
1742{
1843
1944 /**
2045 * @param list<string> $errors
21- * @param array<array<mixed> > $otherIgnoreErrors
22- * @param array<string, array<array<mixed> >> $ignoreErrorsByFile
23- * @param (string|mixed[] )[] $ignoreErrors
46+ * @param array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData} > $otherIgnoreErrors
47+ * @param array<string, array<array{index: int<0, max>, ignoreError: string|ExpandedIgnoredErrorData} >> $ignoreErrorsByFile
48+ * @param (string|ExpandedIgnoredErrorData )[] $ignoreErrors
2449 */
2550 public function __construct (
2651 private FileHelper $ fileHelper ,
@@ -55,7 +80,14 @@ public function process(
5580 $ unmatchedIgnoredErrors = $ this ->ignoreErrors ;
5681 $ stringErrors = [];
5782
58- $ processIgnoreError = function (Error $ error , int $ i , $ ignore ) use (&$ unmatchedIgnoredErrors , &$ stringErrors ): bool {
83+ // Per-entry runtime state for `count`-bounded ignores. Tracked in side
84+ // maps keyed by the same index so `$unmatchedIgnoredErrors` keeps the
85+ // `(string|ExpandedIgnoredErrorData)[]` shape across the closure's
86+ // offset writes — otherwise PHPStan widens it to `array<mixed>`.
87+ $ realCounts = [];
88+ $ matchedAt = [];
89+
90+ $ processIgnoreError = function (Error $ error , int $ i , $ ignore ) use (&$ unmatchedIgnoredErrors , &$ stringErrors , &$ realCounts , &$ matchedAt ): bool {
5991 $ shouldBeIgnored = false ;
6092 if (is_string ($ ignore )) {
6193 $ shouldBeIgnored = IgnoredError::shouldIgnore ($ this ->fileHelper , $ error , ignoredErrorPattern: $ ignore , ignoredErrorMessage: null , identifier: null , path: null );
@@ -67,13 +99,11 @@ public function process(
6799 $ shouldBeIgnored = IgnoredError::shouldIgnore ($ this ->fileHelper , $ error , ignoredErrorPattern: $ ignore ['message ' ] ?? null , ignoredErrorMessage: $ ignore ['rawMessage ' ] ?? null , identifier: $ ignore ['identifier ' ] ?? null , path: $ ignore ['path ' ]);
68100 if ($ shouldBeIgnored ) {
69101 if (isset ($ ignore ['count ' ])) {
70- $ realCount = $ unmatchedIgnoredErrors [$ i ]['realCount ' ] ?? 0 ;
71- $ realCount ++;
72- $ unmatchedIgnoredErrors [$ i ]['realCount ' ] = $ realCount ;
102+ $ realCount = ($ realCounts [$ i ] ?? 0 ) + 1 ;
103+ $ realCounts [$ i ] = $ realCount ;
73104
74- if (!isset ($ unmatchedIgnoredErrors [$ i ]['file ' ])) {
75- $ unmatchedIgnoredErrors [$ i ]['file ' ] = $ error ->getFile ();
76- $ unmatchedIgnoredErrors [$ i ]['line ' ] = $ error ->getLine ();
105+ if (!isset ($ matchedAt [$ i ])) {
106+ $ matchedAt [$ i ] = ['file ' => $ error ->getFile (), 'line ' => $ error ->getLine ()];
77107 }
78108
79109 if ($ realCount > $ ignore ['count ' ]) {
@@ -171,48 +201,59 @@ public function process(
171201
172202 $ errors = array_values ($ errors );
173203
174- foreach ($ unmatchedIgnoredErrors as $ unmatchedIgnoredError ) {
175- if (!isset ($ unmatchedIgnoredError ['count ' ]) || !isset ($ unmatchedIgnoredError [ ' realCount ' ])) {
204+ foreach ($ unmatchedIgnoredErrors as $ i => $ unmatchedIgnoredError ) {
205+ if (!is_array ( $ unmatchedIgnoredError ) || ! isset ($ unmatchedIgnoredError ['count ' ]) || !isset ($ realCounts [ $ i ])) {
176206 continue ;
177207 }
178208
179- if ($ unmatchedIgnoredError ['realCount ' ] <= $ unmatchedIgnoredError ['count ' ]) {
209+ $ realCount = $ realCounts [$ i ];
210+ if ($ realCount <= $ unmatchedIgnoredError ['count ' ]) {
180211 continue ;
181212 }
182213
214+ $ matchedFile = $ matchedAt [$ i ]['file ' ] ?? null ;
215+ $ matchedLine = $ matchedAt [$ i ]['line ' ] ?? null ;
216+
183217 $ errors [] = (new Error (sprintf (
184218 '%s %s is expected to occur %d %s, but occurred %d %s. ' ,
185219 IgnoredError::getIgnoredErrorLabel ($ unmatchedIgnoredError ),
186220 IgnoredError::stringifyPattern ($ unmatchedIgnoredError ),
187221 $ unmatchedIgnoredError ['count ' ],
188222 $ unmatchedIgnoredError ['count ' ] === 1 ? 'time ' : 'times ' ,
189- $ unmatchedIgnoredError [ ' realCount ' ] ,
190- $ unmatchedIgnoredError [ ' realCount ' ] === 1 ? 'time ' : 'times ' ,
191- ), $ unmatchedIgnoredError [ ' file ' ] , $ unmatchedIgnoredError [ ' line ' ] , false ))->withIdentifier ('ignore.count ' );
223+ $ realCount ,
224+ $ realCount === 1 ? 'time ' : 'times ' ,
225+ ), $ matchedFile ?? '' , $ matchedLine , false ))->withIdentifier ('ignore.count ' );
192226 }
193227
194228 $ analysedFilesKeys = array_fill_keys ($ analysedFiles , true );
195229
196230 if (!$ hasInternalErrors ) {
197- foreach ($ unmatchedIgnoredErrors as $ unmatchedIgnoredError ) {
198- $ reportUnmatched = $ unmatchedIgnoredError ['reportUnmatched ' ] ?? $ this ->reportUnmatchedIgnoredErrors ;
231+ foreach ($ unmatchedIgnoredErrors as $ i => $ unmatchedIgnoredError ) {
232+ $ reportUnmatched = is_array ($ unmatchedIgnoredError )
233+ ? ($ unmatchedIgnoredError ['reportUnmatched ' ] ?? $ this ->reportUnmatchedIgnoredErrors )
234+ : $ this ->reportUnmatchedIgnoredErrors ;
199235 if ($ reportUnmatched === false ) {
200236 continue ;
201237 }
238+ $ realCount = $ realCounts [$ i ] ?? null ;
202239 if (
203- isset ($ unmatchedIgnoredError ['count ' ], $ unmatchedIgnoredError ['realCount ' ])
240+ isset ($ unmatchedIgnoredError ['count ' ])
241+ && $ realCount !== null
204242 && (isset ($ unmatchedIgnoredError ['realPath ' ]) || !$ onlyFiles )
205243 ) {
206- if ($ unmatchedIgnoredError ['realCount ' ] < $ unmatchedIgnoredError ['count ' ]) {
244+ if ($ realCount < $ unmatchedIgnoredError ['count ' ]) {
245+ $ matchedFile = $ matchedAt [$ i ]['file ' ] ?? null ;
246+ $ matchedLine = $ matchedAt [$ i ]['line ' ] ?? null ;
247+ // $realCount is at least 1 (it was incremented in the closure)
248+ // and strictly less than count, so count is always >= 2.
207249 $ errors [] = (new Error (sprintf (
208- '%s %s is expected to occur %d %s , but occurred only %d %s. ' ,
250+ '%s %s is expected to occur %d times , but occurred only %d %s. ' ,
209251 IgnoredError::getIgnoredErrorLabel ($ unmatchedIgnoredError ),
210252 IgnoredError::stringifyPattern ($ unmatchedIgnoredError ),
211253 $ unmatchedIgnoredError ['count ' ],
212- $ unmatchedIgnoredError ['count ' ] === 1 ? 'time ' : 'times ' ,
213- $ unmatchedIgnoredError ['realCount ' ],
214- $ unmatchedIgnoredError ['realCount ' ] === 1 ? 'time ' : 'times ' ,
215- ), $ unmatchedIgnoredError ['file ' ], $ unmatchedIgnoredError ['line ' ], false ))->withIdentifier ('ignore.count ' );
254+ $ realCount ,
255+ $ realCount === 1 ? 'time ' : 'times ' ,
256+ ), $ matchedFile ?? '' , $ matchedLine , false ))->withIdentifier ('ignore.count ' );
216257 }
217258 } elseif (isset ($ unmatchedIgnoredError ['realPath ' ])) {
218259 if (!array_key_exists ($ unmatchedIgnoredError ['realPath ' ], $ analysedFilesKeys )) {
0 commit comments