From 9cffdb4e93bb3e741b7d5bd3651c6903880f433a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 25 Oct 2026 09:49:49 -0700 Subject: [PATCH 1/2] Infer literal types for word date format characters The `a`, `A`, `S`, `D`, `l`, `M`, and `F` format characters of `date()`, `date_format()`, and `DateTimeInterface::format()` now produce unions of their possible values, like the numeric single-character formats already do. PHP formats these characters from hard-coded English tables, so the values do not depend on the locale. The final `union()` with `non-empty-string` in `getTypeFromFormatType()` is removed, since it would otherwise widen these literal unions (and any other non-numeric constant result, such as `date(' ')`) to `non-empty-string`. A non-empty format yielding a non-empty result is already guaranteed: a non-constant format is handled by the `non-empty-string` branch above, and every type built for a constant non-empty format is itself non-empty. Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/DateFunctionReturnTypeHelper.php | 34 +++++++++++++++---- tests/PHPStan/Analyser/nsrt/bug-15537.php | 29 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/date-format.php | 10 +++--- 3 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15537.php diff --git a/src/Type/Php/DateFunctionReturnTypeHelper.php b/src/Type/Php/DateFunctionReturnTypeHelper.php index 3cce51593b2..88464acf0d7 100644 --- a/src/Type/Php/DateFunctionReturnTypeHelper.php +++ b/src/Type/Php/DateFunctionReturnTypeHelper.php @@ -37,12 +37,6 @@ public function getTypeFromFormatType(Type $formatType, bool $useMicrosec): Type $type = TypeCombinator::union(...$types); } - if ($type->isNumericString()->no() && $formatType->isNonEmptyString()->yes()) { - $type = TypeCombinator::union($type, new IntersectionType([ - new StringType(), new AccessoryNonEmptyStringType(), - ])); - } - return $type; } @@ -84,6 +78,20 @@ private function buildReturnTypeFromFormat(string $formatString, bool $useMicros return $useMicrosec ? new IntersectionType([new StringType(), new AccessoryNonFalsyStringType(), new AccessoryNumericStringType()]) : new ConstantStringType('000'); + case 'a': + return $this->buildConstantStringsType(['am', 'pm']); + case 'A': + return $this->buildConstantStringsType(['AM', 'PM']); + case 'S': + return $this->buildConstantStringsType(['st', 'nd', 'rd', 'th']); + case 'D': + return $this->buildConstantStringsType(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + case 'l': + return $this->buildConstantStringsType(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']); + case 'M': + return $this->buildConstantStringsType(['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']); + case 'F': + return $this->buildConstantStringsType(['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']); } $date = date($formatString); @@ -117,4 +125,18 @@ private function buildNumericRangeType(int $min, int $max, bool $zeroPad): Type return new UnionType($types); } + /** + * @param list $values + */ + private function buildConstantStringsType(array $values): Type + { + $types = []; + + foreach ($values as $value) { + $types[] = new ConstantStringType($value); + } + + return new UnionType($types); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15537.php b/tests/PHPStan/Analyser/nsrt/bug-15537.php new file mode 100644 index 00000000000..66ec43762e6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15537.php @@ -0,0 +1,29 @@ +format( 'a' ) ); + echo translate_meridiem( date( 'A', $timestamp ) ); + + assertType( "'am'|'pm'", $datetime->format( 'a' ) ); + assertType( "'AM'|'PM'", $datetime->format( 'A' ) ); + assertType( "'nd'|'rd'|'st'|'th'", $datetime->format( 'S' ) ); + assertType( "'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", $datetime->format( 'D' ) ); + assertType( "'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday'", $datetime->format( 'l' ) ); + assertType( "'Apr'|'Aug'|'Dec'|'Feb'|'Jan'|'Jul'|'Jun'|'Mar'|'May'|'Nov'|'Oct'|'Sep'", $datetime->format( 'M' ) ); + assertType( "'April'|'August'|'December'|'February'|'January'|'July'|'June'|'March'|'May'|'November'|'October'|'September'", $datetime->format( 'F' ) ); + + assertType( "'am'|'pm'", date( 'a', $timestamp ) ); + assertType( "'Friday'|'Monday'|'Saturday'|'Sunday'|'Thursday'|'Tuesday'|'Wednesday'", date_format( $datetime, 'l' ) ); +} diff --git a/tests/PHPStan/Analyser/nsrt/date-format.php b/tests/PHPStan/Analyser/nsrt/date-format.php index e01319f2804..cd7b0ba7ab5 100644 --- a/tests/PHPStan/Analyser/nsrt/date-format.php +++ b/tests/PHPStan/Analyser/nsrt/date-format.php @@ -7,7 +7,7 @@ function (string $s): void { assertType('\'\'', date('')); assertType('string', date($s)); - assertType('non-falsy-string', date('D')); + assertType("'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", date('D')); assertType('numeric-string', date('Y')); assertType('numeric-string', date('Ghi')); }; @@ -15,7 +15,7 @@ function (string $s): void { function (\DateTime $dt, string $s): void { assertType('\'\'', date_format($dt, '')); assertType('string', date_format($dt, $s)); - assertType('non-falsy-string', date_format($dt, 'D')); + assertType("'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", date_format($dt, 'D')); assertType('numeric-string', date_format($dt, 'Y')); assertType('numeric-string', date_format($dt, 'Ghi')); }; @@ -23,7 +23,7 @@ function (\DateTime $dt, string $s): void { function (\DateTimeInterface $dt, string $s): void { assertType('\'\'', $dt->format('')); assertType('string', $dt->format($s)); - assertType('non-falsy-string', $dt->format('D')); + assertType("'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", $dt->format('D')); assertType('numeric-string', $dt->format('Y')); assertType('numeric-string', $dt->format('Ghi')); }; @@ -31,7 +31,7 @@ function (\DateTimeInterface $dt, string $s): void { function (\DateTime $dt, string $s): void { assertType('\'\'', $dt->format('')); assertType('string', $dt->format($s)); - assertType('non-falsy-string', $dt->format('D')); + assertType("'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", $dt->format('D')); assertType('numeric-string', $dt->format('Y')); assertType('numeric-string', $dt->format('Ghi')); }; @@ -39,7 +39,7 @@ function (\DateTime $dt, string $s): void { function (\DateTimeImmutable $dt, string $s): void { assertType('\'\'', $dt->format('')); assertType('string', $dt->format($s)); - assertType('non-falsy-string', $dt->format('D')); + assertType("'Fri'|'Mon'|'Sat'|'Sun'|'Thu'|'Tue'|'Wed'", $dt->format('D')); assertType('numeric-string', $dt->format('Y')); assertType('numeric-string', $dt->format('Ghi')); }; From ec74e1448c4ab6f411dba89369755819eaea3a85 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Sun, 25 Oct 2026 15:51:48 -0700 Subject: [PATCH 2/2] Allow the "nd" ordinal suffix in the typos check The "S" date format returns English ordinal suffixes, and typos reports the "nd" string literal as a misspelling of "and". Co-Authored-By: Claude Opus 5.5 --- .typos.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.typos.toml b/.typos.toml index 42b8a797374..262c9605bf6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -16,3 +16,5 @@ is_writeable = "is_writeable" [default.extend-words] # override false-positives Excluder = "Excluder" +# English ordinal suffix ("2nd"), from the "S" date format +nd = "nd"