├── src ├── Clock.php ├── Field │ ├── TimeZoneOffsetSign.php │ ├── TimeZoneRegion.php │ ├── FractionOfSecond.php │ ├── SecondOfDay.php │ ├── NanoOfSecond.php │ ├── DayOfWeek.php │ ├── HourOfDay.php │ ├── MinuteOfHour.php │ ├── SecondOfMinute.php │ ├── TimeZoneOffsetHour.php │ ├── TimeZoneOffsetMinute.php │ ├── TimeZoneOffsetSecond.php │ ├── DayOfYear.php │ ├── Year.php │ ├── TimeZoneOffsetTotalSeconds.php │ ├── DayOfMonth.php │ ├── WeekOfYear.php │ └── MonthOfYear.php ├── Parser │ ├── DateTimeParser.php │ ├── DateTimeParseException.php │ ├── PatternParser.php │ ├── DateTimeParseResult.php │ ├── PatternParserBuilder.php │ └── IsoParsers.php ├── Clock │ ├── SystemClock.php │ ├── OffsetClock.php │ ├── FixedClock.php │ └── ScaleClock.php ├── Quarter.php ├── DateTimeException.php ├── Utility │ └── Math.php ├── Stopwatch.php ├── DayOfWeek.php ├── DefaultClock.php ├── TimeZone.php ├── TimeZoneRegion.php ├── Month.php ├── Interval.php ├── TimeZoneOffset.php ├── YearMonthRange.php ├── MonthDay.php ├── LocalDateRange.php ├── Year.php ├── YearMonth.php ├── YearWeek.php ├── Instant.php ├── Period.php ├── LocalTime.php └── LocalDateTime.php ├── LICENSE └── composer.json /src/Clock.php: -------------------------------------------------------------------------------- 1 | 86399) { 27 | throw DateTimeException::fieldNotInRange(self::NAME, $secondOfDay, 0, 86399); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Field/NanoOfSecond.php: -------------------------------------------------------------------------------- 1 | 999_999_999) { 27 | throw DateTimeException::fieldNotInRange(self::NAME, $nanoOfSecond, 0, 999_999_999); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Field/DayOfWeek.php: -------------------------------------------------------------------------------- 1 | $dayOfWeek 25 | */ 26 | public static function check(int $dayOfWeek): void 27 | { 28 | if ($dayOfWeek < 1 || $dayOfWeek > 7) { 29 | throw DateTimeException::fieldNotInRange(self::NAME, $dayOfWeek, 1, 7); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Clock/OffsetClock.php: -------------------------------------------------------------------------------- 1 | referenceClock->getTime()->plus($this->offset); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Field/HourOfDay.php: -------------------------------------------------------------------------------- 1 | 23) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $hourOfDay, 0, 23); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Field/MinuteOfHour.php: -------------------------------------------------------------------------------- 1 | 59) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $minuteOfHour, 0, 59); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Field/SecondOfMinute.php: -------------------------------------------------------------------------------- 1 | 59) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $secondOfMinute, 0, 59); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Field/TimeZoneOffsetHour.php: -------------------------------------------------------------------------------- 1 | 18) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $offsetHour, -18, 18); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Field/TimeZoneOffsetMinute.php: -------------------------------------------------------------------------------- 1 | 59) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $offsetMinute, -59, 59); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Field/TimeZoneOffsetSecond.php: -------------------------------------------------------------------------------- 1 | 59) { 32 | throw DateTimeException::fieldNotInRange(self::NAME, $offsetSecond, -59, 59); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Clock/FixedClock.php: -------------------------------------------------------------------------------- 1 | instant; 27 | } 28 | 29 | public function setTime(Instant $instant): void 30 | { 31 | $this->instant = $instant; 32 | } 33 | 34 | /** 35 | * Moves the clock by a number of seconds and/or nanos. 36 | */ 37 | public function move(int $seconds, int $nanos = 0): void 38 | { 39 | $duration = Duration::ofSeconds($seconds, $nanos); 40 | $this->instant = $this->instant->plus($duration); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Field/DayOfYear.php: -------------------------------------------------------------------------------- 1 | $dayOfYear 26 | */ 27 | public static function check(int $dayOfYear, ?int $year = null): void 28 | { 29 | if ($dayOfYear < 1 || $dayOfYear > 366) { 30 | throw DateTimeException::fieldNotInRange(self::NAME, $dayOfYear, 1, 366); 31 | } 32 | 33 | if ($dayOfYear === 366 && $year !== null && ! Year::isLeap($year)) { 34 | throw new DateTimeException("Invalid day-of-year 366 as $year is not a leap year"); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Quarter.php: -------------------------------------------------------------------------------- 1 | getQuarter(); 42 | } 43 | 44 | /** 45 | * Serializes as an integer. 46 | */ 47 | public function jsonSerialize(): int 48 | { 49 | return $this->value; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-present Benjamin Morel 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/DateTimeException.php: -------------------------------------------------------------------------------- 1 | 1 makes the time move at an accelerated pace; 23 | * - a scale == 1 makes the time move at the normal pace; 24 | * - a scale == 0 freezes the current time; 25 | * - a scale < 0 makes the time move backwards. 26 | * 27 | * @param Clock $referenceClock The reference clock. 28 | * @param int $timeScale The time scale. 29 | */ 30 | public function __construct( 31 | private readonly Clock $referenceClock, 32 | private readonly int $timeScale, 33 | ) { 34 | $this->startTime = $this->referenceClock->getTime(); 35 | } 36 | 37 | public function getTime(): Instant 38 | { 39 | $duration = Duration::between($this->startTime, $this->referenceClock->getTime()); 40 | $duration = $duration->multipliedBy($this->timeScale); 41 | 42 | return $this->startTime->plus($duration); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Field/Year.php: -------------------------------------------------------------------------------- 1 | self::MAX_VALUE) { 42 | throw DateTimeException::fieldNotInRange(self::NAME, $year, self::MIN_VALUE, self::MAX_VALUE); 43 | } 44 | } 45 | 46 | /** 47 | * @param int $year The year, validated. 48 | */ 49 | public static function isLeap(int $year): bool 50 | { 51 | return (($year & 3) === 0) && (($year % 100) !== 0 || ($year % 400) === 0); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Field/TimeZoneOffsetTotalSeconds.php: -------------------------------------------------------------------------------- 1 | self::MAX_SECONDS) { 36 | throw DateTimeException::fieldNotInRange(self::NAME, $offsetSeconds, -self::MAX_SECONDS, self::MAX_SECONDS); 37 | } 38 | 39 | if ($offsetSeconds % 60 !== 0 && PHP_VERSION_ID < 8_01_07) { 40 | throw DateTimeException::timeZoneOffsetSecondsMustBeMultipleOf60($offsetSeconds); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Parser/PatternParser.php: -------------------------------------------------------------------------------- 1 | pattern; 28 | } 29 | 30 | /** 31 | * @return string[] 32 | */ 33 | public function getFields(): array 34 | { 35 | return $this->fields; 36 | } 37 | 38 | public function parse(string $text): DateTimeParseResult 39 | { 40 | $pattern = '/^' . $this->pattern . '$/'; 41 | 42 | if (preg_match($pattern, $text, $matches) !== 1) { 43 | throw new DateTimeParseException(sprintf('Failed to parse "%s".', $text)); 44 | } 45 | 46 | $result = new DateTimeParseResult(); 47 | 48 | $index = 1; 49 | 50 | foreach ($this->fields as $field) { 51 | if (isset($matches[$index]) && $matches[$index] !== '') { 52 | $result->addField($field, $matches[$index]); 53 | } 54 | 55 | $index++; 56 | } 57 | 58 | return $result; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Field/DayOfMonth.php: -------------------------------------------------------------------------------- 1 | |null $monthOfYear An optional month-of-year to check against. 27 | * @param int|null $year An optional year to check against, validated. 28 | * 29 | * @throws DateTimeException If the day-of-month is not valid. 30 | * 31 | * @psalm-assert int<1, 31> $dayOfMonth 32 | */ 33 | public static function check(int $dayOfMonth, ?int $monthOfYear = null, ?int $year = null): void 34 | { 35 | if ($dayOfMonth < 1 || $dayOfMonth > 31) { 36 | throw DateTimeException::fieldNotInRange(self::NAME, $dayOfMonth, 1, 31); 37 | } 38 | 39 | if ($monthOfYear === null) { 40 | return; 41 | } 42 | 43 | $monthLength = MonthOfYear::getLength($monthOfYear, $year); 44 | 45 | if ($dayOfMonth > $monthLength) { 46 | if ($dayOfMonth === 29) { 47 | throw new DateTimeException("Invalid date February 29 as $year is not a leap year"); 48 | } 49 | 50 | $monthName = MonthOfYear::getName($monthOfYear); 51 | 52 | throw new DateTimeException("Invalid date $monthName $dayOfMonth"); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/Parser/DateTimeParseResult.php: -------------------------------------------------------------------------------- 1 | > 17 | */ 18 | private array $fields = []; 19 | 20 | public function addField(string $name, string $value): void 21 | { 22 | $this->fields[$name][] = $value; 23 | } 24 | 25 | /** 26 | * Returns whether this result has at least one value for the given field. 27 | */ 28 | public function hasField(string $name): bool 29 | { 30 | return isset($this->fields[$name]) && $this->fields[$name] !== []; 31 | } 32 | 33 | /** 34 | * Returns the first value parsed for the given field. 35 | * 36 | * @param string $name One of the field constants. 37 | * 38 | * @return string The value for this field. 39 | * 40 | * @throws DateTimeParseException If the field is not present in this set. 41 | */ 42 | public function getField(string $name): string 43 | { 44 | $value = $this->getOptionalField($name); 45 | 46 | if ($value === '') { 47 | throw new DateTimeParseException(sprintf('Field %s is not present in the parsed result.', $name)); 48 | } 49 | 50 | return $value; 51 | } 52 | 53 | /** 54 | * Returns the first value for the given field, or an empty string if not present. 55 | */ 56 | public function getOptionalField(string $name): string 57 | { 58 | if (isset($this->fields[$name])) { 59 | if ($this->fields[$name] !== []) { 60 | return array_shift($this->fields[$name]); 61 | } 62 | } 63 | 64 | return ''; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Utility/Math.php: -------------------------------------------------------------------------------- 1 | 53) { 36 | throw DateTimeException::fieldNotInRange(self::NAME, $weekOfYear, 1, 53); 37 | } 38 | 39 | if ($weekOfYear === 53 && $year !== null && ! self::is53WeekYear($year)) { 40 | throw new DateTimeException("Year $year does not have 53 weeks"); 41 | } 42 | } 43 | 44 | /** 45 | * Returns whether the given year has 53 weeks. 46 | * 47 | * A year as 53 weeks if the year starts on a Thursday, or Wednesday in a leap year. 48 | * 49 | * @param int $year The year, validated. 50 | * 51 | * @return bool True if 53 weeks, false if 52 weeks. 52 | */ 53 | public static function is53WeekYear(int $year): bool 54 | { 55 | $date = LocalDate::of($year, Month::JANUARY, 1); 56 | $dayOfWeek = $date->getDayOfWeek(); 57 | 58 | return $dayOfWeek === DayOfWeek::THURSDAY 59 | || ($dayOfWeek === DayOfWeek::WEDNESDAY && $date->isLeapYear()); 60 | } 61 | 62 | /** 63 | * Returns the number of weeks in the given year. 64 | * 65 | * @param int $year The year, validated. 66 | * 67 | * @return int The number of weeks in the year. 68 | */ 69 | public static function getWeeksInYear(int $year): int 70 | { 71 | return self::is53WeekYear($year) ? 53 : 52; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/Field/MonthOfYear.php: -------------------------------------------------------------------------------- 1 | $monthOfYear 30 | */ 31 | public static function check(int $monthOfYear): void 32 | { 33 | if ($monthOfYear < 1 || $monthOfYear > 12) { 34 | throw DateTimeException::fieldNotInRange(self::NAME, $monthOfYear, 1, 12); 35 | } 36 | } 37 | 38 | /** 39 | * Returns the length of the given month-of-year. 40 | * 41 | * If no year is given, the highest value (29) is returned for the month of February. 42 | * 43 | * @param int $monthOfYear The month-of-year, validated. 44 | * @param int|null $year An optional year the month-of-year belongs to, validated. 45 | * 46 | * @return int<28, 31> 47 | */ 48 | public static function getLength(int $monthOfYear, ?int $year = null): int 49 | { 50 | return match ($monthOfYear) { 51 | 2 => ($year === null || Year::isLeap($year)) ? 29 : 28, 52 | 4, 6, 9, 11 => 30, 53 | default => 31, 54 | }; 55 | } 56 | 57 | /** 58 | * Returns the camel-cased English name of the given month-of-year. 59 | * 60 | * @param int<1, 12> $monthOfYear The month-of-year. 61 | */ 62 | public static function getName(int $monthOfYear): string 63 | { 64 | return match ($monthOfYear) { 65 | 1 => 'January', 66 | 2 => 'February', 67 | 3 => 'March', 68 | 4 => 'April', 69 | 5 => 'May', 70 | 6 => 'June', 71 | 7 => 'July', 72 | 8 => 'August', 73 | 9 => 'September', 74 | 10 => 'October', 75 | 11 => 'November', 76 | 12 => 'December', 77 | }; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/Parser/PatternParserBuilder.php: -------------------------------------------------------------------------------- 1 | pattern .= $parser->getPattern(); 33 | $this->fields = array_merge($this->fields, $parser->getFields()); 34 | 35 | return $this; 36 | } 37 | 38 | public function appendLiteral(string $literal): self 39 | { 40 | $this->pattern .= preg_quote($literal, '/'); 41 | 42 | return $this; 43 | } 44 | 45 | public function appendCapturePattern(string $pattern, string $field): self 46 | { 47 | $this->pattern .= '(' . $pattern . ')'; 48 | $this->fields[] = $field; 49 | 50 | return $this; 51 | } 52 | 53 | public function startOptional(): self 54 | { 55 | $this->pattern .= '(?:'; 56 | $this->stack[] = 'O'; 57 | 58 | return $this; 59 | } 60 | 61 | public function endOptional(): self 62 | { 63 | if (array_pop($this->stack) !== 'O') { 64 | throw new RuntimeException('Cannot call endOptional() without a call to startOptional() first.'); 65 | } 66 | 67 | $this->pattern .= ')?'; 68 | 69 | return $this; 70 | } 71 | 72 | public function startGroup(): self 73 | { 74 | $this->pattern .= '(?:'; 75 | $this->stack[] = 'G'; 76 | 77 | return $this; 78 | } 79 | 80 | public function endGroup(): self 81 | { 82 | if (array_pop($this->stack) !== 'G') { 83 | throw new RuntimeException('Cannot call endGroup() without a call to startGroup() first.'); 84 | } 85 | 86 | $this->pattern .= ')'; 87 | 88 | return $this; 89 | } 90 | 91 | public function appendOr(): self 92 | { 93 | $this->pattern .= '|'; 94 | 95 | return $this; 96 | } 97 | 98 | public function toParser(): PatternParser 99 | { 100 | if ($this->stack !== []) { 101 | throw new RuntimeException('Builder misses call to endOptional() or endGroup().'); 102 | } 103 | 104 | return new PatternParser($this->pattern, $this->fields); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Stopwatch.php: -------------------------------------------------------------------------------- 1 | clock = $clock; 36 | $this->duration = Duration::zero(); 37 | } 38 | 39 | /** 40 | * Starts the timer. 41 | * 42 | * If the timer is already started, this method does nothing. 43 | */ 44 | public function start(): void 45 | { 46 | if ($this->startTime === null) { 47 | $this->startTime = $this->clock->getTime(); 48 | } 49 | } 50 | 51 | /** 52 | * Stops the timer and returns the lap duration. 53 | * 54 | * If the timer is already stopped, this method does nothing, and returns a zero duration. 55 | */ 56 | public function stop(): Duration 57 | { 58 | if ($this->startTime === null) { 59 | return Duration::zero(); 60 | } 61 | 62 | $endTime = $this->clock->getTime(); 63 | $duration = Duration::between($this->startTime, $endTime); 64 | 65 | $this->duration = $this->duration->plus($duration); 66 | $this->startTime = null; 67 | 68 | return $duration; 69 | } 70 | 71 | /** 72 | * Returns the time this stopwatch has been started at, or null if it is not running. 73 | */ 74 | public function getStartTime(): ?Instant 75 | { 76 | return $this->startTime; 77 | } 78 | 79 | public function isRunning(): bool 80 | { 81 | return $this->startTime !== null; 82 | } 83 | 84 | /** 85 | * Returns the total elapsed time. 86 | * 87 | * This includes the times between previous start() and stop() calls if any, 88 | * as well as the time since the stopwatch was last started if it is running. 89 | */ 90 | public function getElapsedTime(): Duration 91 | { 92 | if ($this->startTime === null) { 93 | return $this->duration; 94 | } 95 | 96 | return $this->duration->plus(Duration::between($this->startTime, $this->clock->getTime())); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/DayOfWeek.php: -------------------------------------------------------------------------------- 1 | getDayOfWeek(); 30 | } 31 | 32 | /** 33 | * Returns the seven days of the week in an array. 34 | * 35 | * @param DayOfWeek $first The day to return first. Optional, defaults to Monday. 36 | * 37 | * @return DayOfWeek[] 38 | */ 39 | public static function all(DayOfWeek $first = DayOfWeek::MONDAY): array 40 | { 41 | $days = []; 42 | $current = $first; 43 | 44 | do { 45 | $days[] = $current; 46 | $current = $current->plus(1); 47 | } while ($current !== $first); 48 | 49 | return $days; 50 | } 51 | 52 | /** 53 | * Returns whether this DayOfWeek is Monday to Friday. 54 | */ 55 | public function isWeekday(): bool 56 | { 57 | return $this->value <= self::FRIDAY->value; 58 | } 59 | 60 | /** 61 | * Returns whether this DayOfWeek is Saturday or Sunday. 62 | */ 63 | public function isWeekend(): bool 64 | { 65 | return $this === self::SATURDAY || $this === self::SUNDAY; 66 | } 67 | 68 | /** 69 | * Returns the DayOfWeek that is the specified number of days after this one. 70 | */ 71 | public function plus(int $days): DayOfWeek 72 | { 73 | return DayOfWeek::from((((($this->value - 1 + $days) % 7) + 7) % 7) + 1); 74 | } 75 | 76 | /** 77 | * Returns the DayOfWeek that is the specified number of days before this one. 78 | */ 79 | public function minus(int $days): DayOfWeek 80 | { 81 | return $this->plus(-$days); 82 | } 83 | 84 | /** 85 | * Serializes as a string using {@see DayOfWeek::toString()}. 86 | * 87 | * @psalm-return non-empty-string 88 | */ 89 | public function jsonSerialize(): string 90 | { 91 | return $this->toString(); 92 | } 93 | 94 | /** 95 | * Returns the capitalized English name of this day-of-week. 96 | * 97 | * @psalm-return non-empty-string 98 | */ 99 | public function toString(): string 100 | { 101 | return match ($this) { 102 | self::MONDAY => 'Monday', 103 | self::TUESDAY => 'Tuesday', 104 | self::WEDNESDAY => 'Wednesday', 105 | self::THURSDAY => 'Thursday', 106 | self::FRIDAY => 'Friday', 107 | self::SATURDAY => 'Saturday', 108 | self::SUNDAY => 'Sunday', 109 | }; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/DefaultClock.php: -------------------------------------------------------------------------------- 1 | getTime(), $instant); 75 | 76 | self::set(new OffsetClock($clock, $offset)); 77 | } 78 | 79 | /** 80 | * Travels in time by a duration, which may be forward (positive) or backward (negative). 81 | * 82 | * If the current default clock is frozen, you must `reset()` it first, or the time will stay frozen. 83 | */ 84 | public static function travelBy(Duration $duration): void 85 | { 86 | self::set(new OffsetClock(self::get(), $duration)); 87 | } 88 | 89 | /** 90 | * Makes time move at a given pace. 91 | * 92 | * - a scale > 1 makes the time move at an accelerated pace; 93 | * - a scale == 1 makes the time move at the normal pace; 94 | * - a scale == 0 freezes the current time; 95 | * - a scale < 0 makes the time move backwards. 96 | * 97 | * If the current default clock is frozen, you must `reset()` it first, or the time will stay frozen. 98 | * Multiple calls to `scale()` will result in a clock with the combined scales. 99 | */ 100 | public static function scale(int $timeScale): void 101 | { 102 | self::set(new ScaleClock(self::get(), $timeScale)); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/TimeZone.php: -------------------------------------------------------------------------------- 1 | getId() === $other->getId(); 68 | } 69 | 70 | public static function fromNativeDateTimeZone(DateTimeZone $dateTimeZone): TimeZone 71 | { 72 | $parsed = TimeZone::parse($dateTimeZone->getName()); 73 | 74 | /** 75 | * PHP >= 8.1.7 supports sub-minute offsets, but truncates the seconds in getName(). Only getOffset() returns 76 | * the correct offset including seconds, so let's use it to make a correction if we have an offset-based TZ. 77 | * This has been fixed in PHP 8.1.20 and PHP 8.2.7. 78 | */ 79 | if ($parsed instanceof TimeZoneOffset 80 | && ( 81 | (PHP_VERSION_ID >= 8_01_07 && PHP_VERSION_ID < 8_01_20) 82 | || (PHP_VERSION_ID >= 8_02_00 && PHP_VERSION_ID < 8_02_07) 83 | ) 84 | ) { 85 | return TimeZoneOffset::ofTotalSeconds($dateTimeZone->getOffset(new DateTimeImmutable())); 86 | } 87 | 88 | return $parsed; 89 | } 90 | 91 | /** 92 | * Returns an equivalent native `DateTimeZone` object for this TimeZone. 93 | */ 94 | abstract public function toNativeDateTimeZone(): DateTimeZone; 95 | 96 | /** 97 | * @psalm-return non-empty-string 98 | */ 99 | public function __toString(): string 100 | { 101 | return $this->getId(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/TimeZoneRegion.php: -------------------------------------------------------------------------------- 1 | getField(Field\TimeZoneRegion::NAME); 54 | 55 | return TimeZoneRegion::of($region); 56 | } 57 | 58 | /** 59 | * Returns all the available time-zone identifiers. 60 | * 61 | * @param bool $includeObsolete Whether to include obsolete time-zone identifiers. Defaults to false. 62 | * 63 | * @return string[] An array of time-zone identifiers. 64 | */ 65 | public static function getAllIdentifiers(bool $includeObsolete = false): array 66 | { 67 | return DateTimeZone::listIdentifiers( 68 | $includeObsolete 69 | ? DateTimeZone::ALL_WITH_BC 70 | : DateTimeZone::ALL, 71 | ); 72 | } 73 | 74 | /** 75 | * Returns the time-zone identifiers for the given country. 76 | * 77 | * If the country code is not known, an empty array is returned. 78 | * 79 | * @param string $countryCode The ISO 3166-1 two-letter country code. 80 | * 81 | * @return string[] An array of time-zone identifiers. 82 | */ 83 | public static function getIdentifiersForCountry(string $countryCode): array 84 | { 85 | return DateTimeZone::listIdentifiers(DateTimeZone::PER_COUNTRY, $countryCode); 86 | } 87 | 88 | /** 89 | * Parses a region id, such as 'Europe/London'. 90 | * 91 | * @throws DateTimeParseException 92 | */ 93 | public static function parse(string $text, ?DateTimeParser $parser = null): TimeZoneRegion 94 | { 95 | if ($parser === null) { 96 | $parser = IsoParsers::timeZoneRegion(); 97 | } 98 | 99 | return TimeZoneRegion::from($parser->parse($text)); 100 | } 101 | 102 | public function getId(): string 103 | { 104 | return $this->zone->getName(); 105 | } 106 | 107 | public function getOffset(Instant $pointInTime): int 108 | { 109 | $dateTime = new DateTime('@' . $pointInTime->getEpochSecond(), new DateTimeZone('UTC')); 110 | 111 | return $this->zone->getOffset($dateTime); 112 | } 113 | 114 | public function toNativeDateTimeZone(): DateTimeZone 115 | { 116 | return clone $this->zone; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Month.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | public function getMinLength(): int 33 | { 34 | return match ($this) { 35 | Month::FEBRUARY => 28, 36 | Month::APRIL, Month::JUNE, Month::SEPTEMBER, Month::NOVEMBER => 30, 37 | default => 31, 38 | }; 39 | } 40 | 41 | /** 42 | * Returns the maximum length of this month in days. 43 | * 44 | * @return int<28, 31> 45 | */ 46 | public function getMaxLength(): int 47 | { 48 | return match ($this) { 49 | Month::FEBRUARY => 29, 50 | Month::APRIL, Month::JUNE, Month::SEPTEMBER, Month::NOVEMBER => 30, 51 | default => 31, 52 | }; 53 | } 54 | 55 | /** 56 | * Returns the day-of-year for the first day of this month. 57 | * 58 | * This returns the day-of-year that this month begins on, using the leap 59 | * year flag to determine the length of February. 60 | * 61 | * @return int<1, 336> 62 | */ 63 | public function getFirstDayOfYear(bool $leapYear): int 64 | { 65 | $leap = $leapYear ? 1 : 0; 66 | 67 | return match ($this) { 68 | Month::JANUARY => 1, 69 | Month::FEBRUARY => 32, 70 | Month::MARCH => 60 + $leap, 71 | Month::APRIL => 91 + $leap, 72 | Month::MAY => 121 + $leap, 73 | Month::JUNE => 152 + $leap, 74 | Month::JULY => 182 + $leap, 75 | Month::AUGUST => 213 + $leap, 76 | Month::SEPTEMBER => 244 + $leap, 77 | Month::OCTOBER => 274 + $leap, 78 | Month::NOVEMBER => 305 + $leap, 79 | Month::DECEMBER => 335 + $leap, 80 | }; 81 | } 82 | 83 | /** 84 | * Returns the length of this month in days. 85 | * 86 | * This takes a flag to determine whether to return the length for a leap year or not. 87 | * 88 | * February has 28 days in a standard year and 29 days in a leap year. 89 | * April, June, September and November have 30 days. 90 | * All other months have 31 days. 91 | * 92 | * @return int<28, 31> 93 | */ 94 | public function getLength(bool $leapYear): int 95 | { 96 | return match ($this) { 97 | Month::FEBRUARY => $leapYear ? 29 : 28, 98 | Month::APRIL, Month::JUNE, Month::SEPTEMBER, Month::NOVEMBER => 30, 99 | default => 31, 100 | }; 101 | } 102 | 103 | /** 104 | * Returns the month that is the specified number of months after this one. 105 | * 106 | * The calculation rolls around the end of the year from December to January. 107 | * The specified period may be negative. 108 | */ 109 | public function plus(int $months): Month 110 | { 111 | return Month::from((((($this->value - 1 + $months) % 12) + 12) % 12) + 1); 112 | } 113 | 114 | /** 115 | * Returns the month that is the specified number of months before this one. 116 | * 117 | * The calculation rolls around the start of the year from January to December. 118 | * The specified period may be negative. 119 | */ 120 | public function minus(int $months): Month 121 | { 122 | return $this->plus(-$months); 123 | } 124 | 125 | /** 126 | * Serializes as a string using {@see Month::toString()}. 127 | * 128 | * @psalm-return non-empty-string 129 | */ 130 | public function jsonSerialize(): string 131 | { 132 | return $this->toString(); 133 | } 134 | 135 | /** 136 | * Returns the capitalized English name of this Month. 137 | * 138 | * @psalm-return non-empty-string 139 | */ 140 | public function toString(): string 141 | { 142 | return match ($this) { 143 | Month::JANUARY => 'January', 144 | Month::FEBRUARY => 'February', 145 | Month::MARCH => 'March', 146 | Month::APRIL => 'April', 147 | Month::MAY => 'May', 148 | Month::JUNE => 'June', 149 | Month::JULY => 'July', 150 | Month::AUGUST => 'August', 151 | Month::SEPTEMBER => 'September', 152 | Month::OCTOBER => 'October', 153 | Month::NOVEMBER => 'November', 154 | Month::DECEMBER => 'December', 155 | }; 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/Interval.php: -------------------------------------------------------------------------------- 1 | isBefore($startInclusive)) { 37 | throw new DateTimeException('The end instant must not be before the start instant.'); 38 | } 39 | 40 | return new Interval($startInclusive, $endExclusive); 41 | } 42 | 43 | /** 44 | * Returns the start instant, inclusive, of this Interval. 45 | */ 46 | public function getStart(): Instant 47 | { 48 | return $this->start; 49 | } 50 | 51 | /** 52 | * Returns the end instant, exclusive, of this Interval. 53 | */ 54 | public function getEnd(): Instant 55 | { 56 | return $this->end; 57 | } 58 | 59 | /** 60 | * Returns a copy of this Interval with the start instant altered. 61 | * 62 | * @throws DateTimeException If the given start instant is after the current end instant. 63 | */ 64 | public function withStart(Instant $start): Interval 65 | { 66 | return Interval::of($start, $this->end); 67 | } 68 | 69 | /** 70 | * Returns a copy of this Interval with the end instant altered. 71 | * 72 | * @throws DateTimeException If the given end instant is before the current start instant. 73 | */ 74 | public function withEnd(Instant $end): Interval 75 | { 76 | return Interval::of($this->start, $end); 77 | } 78 | 79 | /** 80 | * Returns a Duration representing the time elapsed in this Interval. 81 | */ 82 | public function getDuration(): Duration 83 | { 84 | return Duration::between($this->start, $this->end); 85 | } 86 | 87 | /** 88 | * Returns whether this Interval contains the given Instant. 89 | */ 90 | public function contains(Instant $instant): bool 91 | { 92 | return $instant->isAfterOrEqualTo($this->start) 93 | && $instant->isBefore($this->end); 94 | } 95 | 96 | /** 97 | * Returns whether this Interval intersects with the given one. 98 | */ 99 | public function intersectsWith(Interval $that): bool 100 | { 101 | [$prev, $next] = $this->start->isBefore($that->start) 102 | ? [$this, $that] 103 | : [$that, $this]; 104 | 105 | return $next->start->isBefore($prev->end); 106 | } 107 | 108 | /** 109 | * Returns an Interval which is an intersection of this one with the given one. 110 | * 111 | * @throws DateTimeException If the Intervals do not intersect. 112 | */ 113 | public function getIntersectionWith(Interval $that): Interval 114 | { 115 | if (! $this->intersectsWith($that)) { 116 | throw new DateTimeException('Intervals "' . $this . '" and "' . $that . '" do not intersect.'); 117 | } 118 | 119 | $latestStart = $this->start->isAfter($that->start) ? $this->start : $that->start; 120 | $earliestEnd = $this->end->isBefore($that->end) ? $this->end : $that->end; 121 | 122 | return new Interval($latestStart, $earliestEnd); 123 | } 124 | 125 | public function isEqualTo(Interval $that): bool 126 | { 127 | return $this->start->isEqualTo($that->start) 128 | && $this->end->isEqualTo($that->end); 129 | } 130 | 131 | /** 132 | * Serializes as a string using {@see Interval::toISOString()}. 133 | * 134 | * @psalm-return non-empty-string 135 | */ 136 | public function jsonSerialize(): string 137 | { 138 | return $this->toISOString(); 139 | } 140 | 141 | /** 142 | * Returns the ISO 8601 representation of this interval. 143 | * 144 | * @psalm-return non-empty-string 145 | */ 146 | public function toISOString(): string 147 | { 148 | return $this->start . '/' . $this->end; 149 | } 150 | 151 | /** 152 | * {@see Interval::toISOString()}. 153 | * 154 | * @psalm-return non-empty-string 155 | */ 156 | public function __toString(): string 157 | { 158 | return $this->toISOString(); 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/TimeZoneOffset.php: -------------------------------------------------------------------------------- 1 | 0 && ($minutes < 0 || $seconds < 0)) 55 | || ($hours < 0 && ($minutes > 0 || $seconds > 0)) 56 | || ($minutes > 0 && $seconds < 0) 57 | || ($minutes < 0 && $seconds > 0); 58 | 59 | if ($err) { 60 | throw new DateTimeException('Time zone offset hours, minutes and seconds must have the same sign'); 61 | } 62 | 63 | $totalSeconds = $hours * LocalTime::SECONDS_PER_HOUR 64 | + $minutes * LocalTime::SECONDS_PER_MINUTE 65 | + $seconds; 66 | 67 | Field\TimeZoneOffsetTotalSeconds::check($totalSeconds); 68 | 69 | return new TimeZoneOffset($totalSeconds); 70 | } 71 | 72 | /** 73 | * Obtains an instance of `TimeZoneOffset` specifying the total offset in seconds. 74 | * 75 | * The offset must be in the range `-18:00` to `+18:00`, which corresponds to -64800 to +64800. 76 | * 77 | * @param int $totalSeconds The total offset in seconds. 78 | * 79 | * @throws DateTimeException 80 | */ 81 | public static function ofTotalSeconds(int $totalSeconds): TimeZoneOffset 82 | { 83 | Field\TimeZoneOffsetTotalSeconds::check($totalSeconds); 84 | 85 | return new TimeZoneOffset($totalSeconds); 86 | } 87 | 88 | public static function utc(): TimeZoneOffset 89 | { 90 | /** @var TimeZoneOffset|null $utc */ 91 | static $utc = null; 92 | 93 | return $utc ??= new TimeZoneOffset(0); 94 | } 95 | 96 | /** 97 | * @throws DateTimeException If the offset is not valid. 98 | * @throws DateTimeParseException If required fields are missing from the result. 99 | */ 100 | public static function from(DateTimeParseResult $result): TimeZoneOffset 101 | { 102 | $sign = $result->getField(Field\TimeZoneOffsetSign::NAME); 103 | 104 | if ($sign === 'Z' || $sign === 'z') { 105 | return TimeZoneOffset::utc(); 106 | } 107 | 108 | $hour = $result->getField(Field\TimeZoneOffsetHour::NAME); 109 | $minute = $result->getOptionalField(Field\TimeZoneOffsetMinute::NAME); 110 | $second = $result->getOptionalField(Field\TimeZoneOffsetSecond::NAME); 111 | 112 | $hour = (int) $hour; 113 | $minute = (int) $minute; 114 | $second = (int) $second; 115 | 116 | if ($sign === '-') { 117 | $hour = -$hour; 118 | $minute = -$minute; 119 | $second = -$second; 120 | } 121 | 122 | return self::of($hour, $minute, $second); 123 | } 124 | 125 | /** 126 | * Parses a time-zone offset. 127 | * 128 | * The following ISO 8601 formats are accepted: 129 | * 130 | * * `Z` - for UTC 131 | * * `±hh` 132 | * * `±hh:mm` 133 | * * `±hh:mm:ss` 134 | * 135 | * Note that ± means either the plus or minus symbol. 136 | * 137 | * @throws DateTimeParseException 138 | */ 139 | public static function parse(string $text, ?DateTimeParser $parser = null): TimeZoneOffset 140 | { 141 | if ($parser === null) { 142 | $parser = IsoParsers::timeZoneOffset(); 143 | } 144 | 145 | return TimeZoneOffset::from($parser->parse($text)); 146 | } 147 | 148 | /** 149 | * Returns the total time-zone offset in seconds. 150 | * 151 | * This is the primary way to access the offset amount. 152 | * It returns the total of the hours, minutes and seconds fields as a 153 | * single offset that can be added to a time. 154 | * 155 | * @return int The total time-zone offset amount in seconds. 156 | */ 157 | public function getTotalSeconds(): int 158 | { 159 | return $this->totalSeconds; 160 | } 161 | 162 | public function getId(): string 163 | { 164 | if ($this->id === null) { 165 | if ($this->totalSeconds < 0) { 166 | $this->id = '-' . LocalTime::ofSecondOfDay(-$this->totalSeconds); 167 | } elseif ($this->totalSeconds > 0) { 168 | $this->id = '+' . LocalTime::ofSecondOfDay($this->totalSeconds); 169 | } else { 170 | $this->id = 'Z'; 171 | } 172 | } 173 | 174 | return $this->id; 175 | } 176 | 177 | public function getOffset(Instant $pointInTime): int 178 | { 179 | return $this->totalSeconds; 180 | } 181 | 182 | public function toNativeDateTimeZone(): DateTimeZone 183 | { 184 | return new DateTimeZone($this->getId()); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/YearMonthRange.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class YearMonthRange implements IteratorAggregate, Countable, JsonSerializable, Stringable 26 | { 27 | /** 28 | * @param YearMonth $start The start year-month, inclusive. 29 | * @param YearMonth $end The end year-month, inclusive, validated as not before the start year-month. 30 | */ 31 | private function __construct( 32 | private readonly YearMonth $start, 33 | private readonly YearMonth $end, 34 | ) { 35 | } 36 | 37 | /** 38 | * Creates an instance of YearMonthRange from a start year-month and an end year-month. 39 | * 40 | * @param YearMonth $start The start year-month, inclusive. 41 | * @param YearMonth $end The end year-month, inclusive. 42 | * 43 | * @throws DateTimeException If the end year-month is before the start year-month. 44 | */ 45 | public static function of(YearMonth $start, YearMonth $end): YearMonthRange 46 | { 47 | if ($end->isBefore($start)) { 48 | throw new DateTimeException('The end year-month must not be before the start year-month.'); 49 | } 50 | 51 | return new YearMonthRange($start, $end); 52 | } 53 | 54 | /** 55 | * Obtains an instance of `YearMonthRange` from a set of date-time fields. 56 | * 57 | * This method is only useful to parsers. 58 | * 59 | * @throws DateTimeException If the year-month range is not valid. 60 | * @throws DateTimeParseException If required fields are missing from the result. 61 | */ 62 | public static function from(DateTimeParseResult $result): YearMonthRange 63 | { 64 | $start = YearMonth::from($result); 65 | 66 | if ($result->hasField(Field\Year::NAME)) { 67 | $end = YearMonth::from($result); 68 | } else { 69 | $end = $start->withMonth((int) $result->getField(Field\MonthOfYear::NAME)); 70 | } 71 | 72 | return YearMonthRange::of($start, $end); 73 | } 74 | 75 | /** 76 | * Obtains an instance of `YearMonthRange` from a text string. 77 | * 78 | * Partial representations are allowed; for example, the following representations are equivalent: 79 | * 80 | * - `2001-02/2001-07` 81 | * - `2001-02/07` 82 | * 83 | * @param string $text The text to parse. 84 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 85 | * 86 | * @throws DateTimeException If either of the year-months is not valid. 87 | * @throws DateTimeParseException If the text string does not follow the expected format. 88 | */ 89 | public static function parse(string $text, ?DateTimeParser $parser = null): YearMonthRange 90 | { 91 | if ($parser === null) { 92 | $parser = IsoParsers::yearMonthRange(); 93 | } 94 | 95 | return YearMonthRange::from($parser->parse($text)); 96 | } 97 | 98 | /** 99 | * Returns the start year-month, inclusive. 100 | */ 101 | public function getStart(): YearMonth 102 | { 103 | return $this->start; 104 | } 105 | 106 | /** 107 | * Returns the end year-month, inclusive. 108 | */ 109 | public function getEnd(): YearMonth 110 | { 111 | return $this->end; 112 | } 113 | 114 | /** 115 | * Returns whether this YearMonthRange is equal to the given one. 116 | * 117 | * @param YearMonthRange $that The range to compare to. 118 | * 119 | * @return bool True if this range equals the given one, false otherwise. 120 | */ 121 | public function isEqualTo(YearMonthRange $that): bool 122 | { 123 | return $this->start->isEqualTo($that->start) && $this->end->isEqualTo($that->end); 124 | } 125 | 126 | /** 127 | * Returns whether this YearMonthRange contains the given year-month. 128 | * 129 | * @param YearMonth $yearMonth The year-month to check. 130 | * 131 | * @return bool True if this range contains the given year-month, false otherwise. 132 | */ 133 | public function contains(YearMonth $yearMonth): bool 134 | { 135 | return $yearMonth->isAfterOrEqualTo($this->start) && $yearMonth->isBeforeOrEqualTo($this->end); 136 | } 137 | 138 | /** 139 | * Returns an iterator for all the year-months contained in this range. 140 | * 141 | * @return Generator 142 | */ 143 | public function getIterator(): Generator 144 | { 145 | for ($current = $this->start; $current->isBeforeOrEqualTo($this->end); $current = $current->plusMonths(1)) { 146 | yield $current; 147 | } 148 | } 149 | 150 | /** 151 | * Returns the number of year-months in this range. 152 | * 153 | * @return int<1, max> The number of year-months. 154 | */ 155 | public function count(): int 156 | { 157 | /** @var int<1, max> */ 158 | return 12 * ($this->end->getYear() - $this->start->getYear()) 159 | + ($this->end->getMonthValue() - $this->start->getMonthValue()) 160 | + 1; 161 | } 162 | 163 | /** 164 | * Returns LocalDateRange that contains all days of this year-months range. 165 | */ 166 | public function toLocalDateRange(): LocalDateRange 167 | { 168 | return LocalDateRange::of( 169 | $this->getStart()->getFirstDay(), 170 | $this->getEnd()->getLastDay(), 171 | ); 172 | } 173 | 174 | /** 175 | * Serializes as a string using {@see YearMonthRange::toISOString()}. 176 | * 177 | * @psalm-return non-empty-string 178 | */ 179 | public function jsonSerialize(): string 180 | { 181 | return $this->toISOString(); 182 | } 183 | 184 | /** 185 | * Returns a string representation of this year-month range. 186 | * 187 | * ISO 8601 does not seem to provide a standard notation for year-month ranges, but we're using the same format as 188 | * date ranges. 189 | * 190 | * @psalm-return non-empty-string 191 | */ 192 | public function toISOString(): string 193 | { 194 | return $this->start . '/' . $this->end; 195 | } 196 | 197 | /** 198 | * {@see YearMonthRange::toISOString()}. 199 | * 200 | * @psalm-return non-empty-string 201 | */ 202 | public function __toString(): string 203 | { 204 | return $this->toISOString(); 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/MonthDay.php: -------------------------------------------------------------------------------- 1 | $month The month-of-year. 25 | * @param int<1, 31> $day The day-of-month, valid for this month. 26 | */ 27 | private function __construct( 28 | private readonly int $month, 29 | private readonly int $day, 30 | ) { 31 | } 32 | 33 | /** 34 | * Obtains an instance of MonthDay. 35 | * 36 | * @param int|Month $month The month-of-year, from 1 (January) to 12 (December). 37 | * @param int $day The day-of-month, from 1 to 31. 38 | * 39 | * @throws DateTimeException If the month-day is not valid. 40 | */ 41 | public static function of(int|Month $month, int $day): MonthDay 42 | { 43 | if (is_int($month)) { 44 | Field\MonthOfYear::check($month); 45 | } else { 46 | $month = $month->value; 47 | } 48 | 49 | Field\DayOfMonth::check($day, $month); 50 | 51 | return new MonthDay($month, $day); 52 | } 53 | 54 | /** 55 | * @throws DateTimeException If the month-day is not valid. 56 | * @throws DateTimeParseException If required fields are missing from the result. 57 | */ 58 | public static function from(DateTimeParseResult $result): MonthDay 59 | { 60 | return MonthDay::of( 61 | (int) $result->getField(Field\MonthOfYear::NAME), 62 | (int) $result->getField(Field\DayOfMonth::NAME), 63 | ); 64 | } 65 | 66 | /** 67 | * Obtains an instance of `LocalDate` from a text string. 68 | * 69 | * @param string $text The text to parse, such as `--12-03`. 70 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 71 | * 72 | * @throws DateTimeException If the date is not valid. 73 | * @throws DateTimeParseException If the text string does not follow the expected format. 74 | */ 75 | public static function parse(string $text, ?DateTimeParser $parser = null): MonthDay 76 | { 77 | if ($parser === null) { 78 | $parser = IsoParsers::monthDay(); 79 | } 80 | 81 | return MonthDay::from($parser->parse($text)); 82 | } 83 | 84 | /** 85 | * Returns the current month-day in the given time-zone, according to the given clock. 86 | * 87 | * If no clock is provided, the system clock is used. 88 | */ 89 | public static function now(TimeZone $timeZone, ?Clock $clock = null): MonthDay 90 | { 91 | $date = LocalDate::now($timeZone, $clock); 92 | 93 | return new MonthDay($date->getMonthValue(), $date->getDayOfMonth()); 94 | } 95 | 96 | /** 97 | * Returns the month-of-year as a Month enum. 98 | */ 99 | public function getMonth(): Month 100 | { 101 | return Month::from($this->month); 102 | } 103 | 104 | /** 105 | * Returns the month-of-year value from 1 to 12. 106 | * 107 | * @return int<1, 12> 108 | */ 109 | public function getMonthValue(): int 110 | { 111 | return $this->month; 112 | } 113 | 114 | /** 115 | * Returns the day-of-month. 116 | * 117 | * @return int<1, 31> 118 | */ 119 | public function getDayOfMonth(): int 120 | { 121 | return $this->day; 122 | } 123 | 124 | /** 125 | * @return -1|0|1 If this date is before, on, or after the given date. 126 | */ 127 | public function compareTo(MonthDay $that): int 128 | { 129 | if ($this->month < $that->month) { 130 | return -1; 131 | } 132 | if ($this->month > $that->month) { 133 | return 1; 134 | } 135 | if ($this->day < $that->day) { 136 | return -1; 137 | } 138 | if ($this->day > $that->day) { 139 | return 1; 140 | } 141 | 142 | return 0; 143 | } 144 | 145 | /** 146 | * Returns whether this month-day is equal to the specified month-day. 147 | */ 148 | public function isEqualTo(MonthDay $that): bool 149 | { 150 | return $this->compareTo($that) === 0; 151 | } 152 | 153 | /** 154 | * Returns whether this month-day is before the specified month-day. 155 | */ 156 | public function isBefore(MonthDay $that): bool 157 | { 158 | return $this->compareTo($that) === -1; 159 | } 160 | 161 | /** 162 | * Returns whether this month-day is after the specified month-day. 163 | */ 164 | public function isAfter(MonthDay $that): bool 165 | { 166 | return $this->compareTo($that) === 1; 167 | } 168 | 169 | /** 170 | * Returns whether the given year is valid for this month-day. 171 | * 172 | * This method checks whether this month and day and the input year form a valid date. 173 | * This can only return false for February 29th. 174 | */ 175 | public function isValidYear(int $year): bool 176 | { 177 | return $this->month !== 2 || $this->day !== 29 || Field\Year::isLeap($year); 178 | } 179 | 180 | /** 181 | * Returns a copy of this MonthDay with the month-of-year altered. 182 | * 183 | * If the day-of-month is invalid for the specified month, the day will 184 | * be adjusted to the last valid day-of-month. 185 | * 186 | * @throws DateTimeException If the month is invalid. 187 | */ 188 | public function withMonth(int|Month $month): MonthDay 189 | { 190 | if (is_int($month)) { 191 | Field\MonthOfYear::check($month); 192 | } else { 193 | $month = $month->value; 194 | } 195 | 196 | if ($month === $this->month) { 197 | return $this; 198 | } 199 | 200 | $lastDay = Field\MonthOfYear::getLength($month); 201 | 202 | return new MonthDay($month, ($lastDay < $this->day) ? $lastDay : $this->day); 203 | } 204 | 205 | /** 206 | * Returns a copy of this MonthDay with the day-of-month altered. 207 | * 208 | * If the day-of-month is invalid for the month, an exception is thrown. 209 | * 210 | * @throws DateTimeException If the day-of-month is invalid for the month. 211 | */ 212 | public function withDay(int $day): MonthDay 213 | { 214 | if ($day === $this->day) { 215 | return $this; 216 | } 217 | 218 | Field\DayOfMonth::check($day, $this->month); 219 | 220 | return new MonthDay($this->month, $day); 221 | } 222 | 223 | /** 224 | * Combines this month-day with a year to create a LocalDate. 225 | * 226 | * This returns a LocalDate formed from this month-day and the specified year. 227 | * 228 | * A month-day of February 29th will be adjusted to February 28th 229 | * in the resulting date if the year is not a leap year. 230 | * 231 | * @throws DateTimeException If the year is invalid. 232 | */ 233 | public function atYear(int $year): LocalDate 234 | { 235 | return LocalDate::of($year, $this->month, $this->isValidYear($year) ? $this->day : 28); 236 | } 237 | 238 | /** 239 | * Serializes as a string using {@see MonthDay::toISOString()}. 240 | * 241 | * @psalm-return non-empty-string 242 | */ 243 | public function jsonSerialize(): string 244 | { 245 | return $this->toISOString(); 246 | } 247 | 248 | /** 249 | * Returns the ISO 8601 representation of this month-day. 250 | * 251 | * @psalm-return non-empty-string 252 | */ 253 | public function toISOString(): string 254 | { 255 | // This code is optimized for high performance 256 | return '--' 257 | . ($this->month < 10 ? '0' . $this->month : $this->month) 258 | . '-' 259 | . ($this->day < 10 ? '0' . $this->day : $this->day); 260 | } 261 | 262 | /** 263 | * {@see MonthDay::toISOString()}. 264 | * 265 | * @psalm-return non-empty-string 266 | */ 267 | public function __toString(): string 268 | { 269 | return $this->toISOString(); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /src/LocalDateRange.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | final class LocalDateRange implements IteratorAggregate, Countable, JsonSerializable, Stringable 28 | { 29 | /** 30 | * @param LocalDate $start The start date, inclusive. 31 | * @param LocalDate $end The end date, inclusive, validated as not before the start date. 32 | */ 33 | private function __construct( 34 | private readonly LocalDate $start, 35 | private readonly LocalDate $end, 36 | ) { 37 | } 38 | 39 | /** 40 | * Creates an instance of LocalDateRange from a start date and an end date. 41 | * 42 | * @param LocalDate $start The start date, inclusive. 43 | * @param LocalDate $end The end date, inclusive. 44 | * 45 | * @throws DateTimeException If the end date is before the start date. 46 | */ 47 | public static function of(LocalDate $start, LocalDate $end): LocalDateRange 48 | { 49 | if ($end->isBefore($start)) { 50 | throw new DateTimeException('The end date must not be before the start date.'); 51 | } 52 | 53 | return new LocalDateRange($start, $end); 54 | } 55 | 56 | /** 57 | * Obtains an instance of `LocalDateRange` from a set of date-time fields. 58 | * 59 | * This method is only useful to parsers. 60 | * 61 | * @throws DateTimeException If the date range is not valid. 62 | * @throws DateTimeParseException If required fields are missing from the result. 63 | */ 64 | public static function from(DateTimeParseResult $result): LocalDateRange 65 | { 66 | $startDate = LocalDate::from($result); 67 | 68 | if ($result->hasField(Field\MonthOfYear::NAME)) { 69 | if ($result->hasField(Field\Year::NAME)) { 70 | $endDate = LocalDate::from($result); 71 | } else { 72 | $endDate = MonthDay::from($result)->atYear($startDate->getYear()); 73 | } 74 | } else { 75 | $endDate = $startDate->withDay((int) $result->getField(Field\DayOfMonth::NAME)); 76 | } 77 | 78 | return LocalDateRange::of($startDate, $endDate); 79 | } 80 | 81 | /** 82 | * Obtains an instance of `LocalDateRange` from a text string. 83 | * 84 | * Partial representations are allowed; for example, the following representations are equivalent: 85 | * 86 | * - `2001-02-03/2001-02-04` 87 | * - `2001-02-03/02-04` 88 | * - `2001-02-03/04` 89 | * 90 | * @param string $text The text to parse. 91 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 92 | * 93 | * @throws DateTimeException If either of the dates is not valid. 94 | * @throws DateTimeParseException If the text string does not follow the expected format. 95 | */ 96 | public static function parse(string $text, ?DateTimeParser $parser = null): LocalDateRange 97 | { 98 | if ($parser === null) { 99 | $parser = IsoParsers::localDateRange(); 100 | } 101 | 102 | return LocalDateRange::from($parser->parse($text)); 103 | } 104 | 105 | /** 106 | * Returns the start date, inclusive. 107 | */ 108 | public function getStart(): LocalDate 109 | { 110 | return $this->start; 111 | } 112 | 113 | /** 114 | * Returns the end date, inclusive. 115 | */ 116 | public function getEnd(): LocalDate 117 | { 118 | return $this->end; 119 | } 120 | 121 | /** 122 | * Returns whether this LocalDateRange is equal to the given one. 123 | * 124 | * @param LocalDateRange $that The range to compare to. 125 | * 126 | * @return bool True if this range equals the given one, false otherwise. 127 | */ 128 | public function isEqualTo(LocalDateRange $that): bool 129 | { 130 | return $this->start->isEqualTo($that->start) 131 | && $this->end->isEqualTo($that->end); 132 | } 133 | 134 | /** 135 | * Returns whether this LocalDateRange contains the given date. 136 | * 137 | * @param LocalDate $date The date to check. 138 | * 139 | * @return bool True if this range contains the given date, false otherwise. 140 | */ 141 | public function contains(LocalDate $date): bool 142 | { 143 | return ! ($date->isBefore($this->start) || $date->isAfter($this->end)); 144 | } 145 | 146 | /** 147 | * Returns whether this LocalDateRange intersects with the given date range. 148 | */ 149 | public function intersectsWith(LocalDateRange $that): bool 150 | { 151 | return $this->contains($that->start) 152 | || $this->contains($that->end) 153 | || $that->contains($this->start) 154 | || $that->contains($this->end); 155 | } 156 | 157 | /** 158 | * Returns the intersection of this LocalDateRange with the given date range. 159 | * 160 | * @throws DateTimeException If the ranges do not intersect. 161 | */ 162 | public function getIntersectionWith(LocalDateRange $that): LocalDateRange 163 | { 164 | if (! $this->intersectsWith($that)) { 165 | throw new DateTimeException('Ranges "' . $this . '" and "' . $that . '" do not intersect.'); 166 | } 167 | 168 | $intersectStart = $this->start->isBefore($that->start) ? $that->start : $this->start; 169 | $intersectEnd = $this->end->isAfter($that->end) ? $that->end : $this->end; 170 | 171 | return new LocalDateRange($intersectStart, $intersectEnd); 172 | } 173 | 174 | /** 175 | * @throws DateTimeException If the start date is after the end date. 176 | */ 177 | public function withStart(LocalDate $start): LocalDateRange 178 | { 179 | if ($start->isEqualTo($this->start)) { 180 | return $this; 181 | } 182 | 183 | return LocalDateRange::of($start, $this->end); 184 | } 185 | 186 | /** 187 | * @throws DateTimeException If the end date is before the start date. 188 | */ 189 | public function withEnd(LocalDate $end): LocalDateRange 190 | { 191 | if ($end->isEqualTo($this->end)) { 192 | return $this; 193 | } 194 | 195 | return LocalDateRange::of($this->start, $end); 196 | } 197 | 198 | /** 199 | * Returns the Period between the start date and end date. 200 | * 201 | * See `Period::between()` for how this is calculated. 202 | */ 203 | public function toPeriod(): Period 204 | { 205 | return Period::between($this->start, $this->end); 206 | } 207 | 208 | /** 209 | * Returns an iterator for all the dates contained in this range. 210 | * 211 | * @return Generator 212 | */ 213 | public function getIterator(): Generator 214 | { 215 | for ($current = $this->start; $current->isBeforeOrEqualTo($this->end); $current = $current->plusDays(1)) { 216 | yield $current; 217 | } 218 | } 219 | 220 | /** 221 | * Returns the number of days in this range. 222 | * 223 | * @return int The number of days, >= 1. 224 | */ 225 | public function count(): int 226 | { 227 | return $this->end->toEpochDay() - $this->start->toEpochDay() + 1; 228 | } 229 | 230 | /** 231 | * Serializes as a string using {@see LocalDateRange::toISOString()}. 232 | * 233 | * @psalm-return non-empty-string 234 | */ 235 | public function jsonSerialize(): string 236 | { 237 | return $this->toISOString(); 238 | } 239 | 240 | /** 241 | * Converts this LocalDateRange to a native DatePeriod object. 242 | * 243 | * The result is a DatePeriod->start with time 00:00 and a DatePeriod->end 244 | * with time 23:59:59.999999 in the UTC time-zone. 245 | */ 246 | public function toNativeDatePeriod(): DatePeriod 247 | { 248 | $start = $this->getStart()->atTime(LocalTime::midnight())->toNativeDateTime(); 249 | $end = $this->getEnd()->atTime(LocalTime::max())->toNativeDateTime(); 250 | $interval = new DateInterval('P1D'); 251 | 252 | return new DatePeriod($start, $interval, $end); 253 | } 254 | 255 | /** 256 | * Returns the ISO 8601 representation of this date range. 257 | * 258 | * @psalm-return non-empty-string 259 | */ 260 | public function toISOString(): string 261 | { 262 | return $this->start . '/' . $this->end; 263 | } 264 | 265 | /** 266 | * {@see LocalDateRange::toISOString()}. 267 | * 268 | * @psalm-return non-empty-string 269 | */ 270 | public function __toString(): string 271 | { 272 | return $this->toISOString(); 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /src/Year.php: -------------------------------------------------------------------------------- 1 | getField(Field\Year::NAME); 52 | 53 | return Year::of($year); 54 | } 55 | 56 | /** 57 | * Obtains an instance of `Year` from a text string. 58 | * 59 | * @param string $text The text to parse, such as `2007`. 60 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 61 | * 62 | * @throws DateTimeException If the year is not valid. 63 | * @throws DateTimeParseException If the text string does not follow the expected format. 64 | */ 65 | public static function parse(string $text, ?DateTimeParser $parser = null): Year 66 | { 67 | if ($parser === null) { 68 | $parser = IsoParsers::year(); 69 | } 70 | 71 | return Year::from($parser->parse($text)); 72 | } 73 | 74 | /** 75 | * Returns the current year in the given time-zone, according to the given clock. 76 | * 77 | * If no clock is provided, the system clock is used. 78 | */ 79 | public static function now(TimeZone $timeZone, ?Clock $clock = null): Year 80 | { 81 | return new Year(LocalDate::now($timeZone, $clock)->getYear()); 82 | } 83 | 84 | public function getValue(): int 85 | { 86 | return $this->year; 87 | } 88 | 89 | /** 90 | * Checks if the year is a leap year, according to the ISO proleptic calendar system rules. 91 | * 92 | * This method applies the current rules for leap years across the whole time-line. 93 | * In general, a year is a leap year if it is divisible by four without 94 | * remainder. However, years divisible by 100, are not leap years, with 95 | * the exception of years divisible by 400 which are. 96 | * 97 | * The calculation is proleptic - applying the same rules into the far future and far past. 98 | * This is historically inaccurate, but is correct for the ISO-8601 standard. 99 | */ 100 | public function isLeap(): bool 101 | { 102 | return Field\Year::isLeap($this->year); 103 | } 104 | 105 | public function isValidMonthDay(MonthDay $monthDay): bool 106 | { 107 | return $monthDay->isValidYear($this->year); 108 | } 109 | 110 | /** 111 | * Returns the length of this year in days. 112 | * 113 | * @return int The length of this year in days, 365 or 366. 114 | */ 115 | public function getLength(): int 116 | { 117 | return $this->isLeap() ? 366 : 365; 118 | } 119 | 120 | /** 121 | * Returns a copy of this year with the specified number of years added. 122 | * 123 | * This instance is immutable and unaffected by this method call. 124 | * 125 | * @param int $years The years to add, may be negative. 126 | * 127 | * @return Year A Year based on this year with the period added. 128 | * 129 | * @throws DateTimeException If the resulting year exceeds the supported range. 130 | */ 131 | public function plus(int $years): Year 132 | { 133 | if ($years === 0) { 134 | return $this; 135 | } 136 | 137 | $year = $this->year + $years; 138 | 139 | Field\Year::check($year); 140 | 141 | return new Year($year); 142 | } 143 | 144 | /** 145 | * Returns a copy of this year with the specified number of years subtracted. 146 | * 147 | * This instance is immutable and unaffected by this method call. 148 | * 149 | * @param int $years The years to subtract, may be negative. 150 | * 151 | * @return Year A Year based on this year with the period subtracted. 152 | * 153 | * @throws DateTimeException If the resulting year exceeds the supported range. 154 | */ 155 | public function minus(int $years): Year 156 | { 157 | if ($years === 0) { 158 | return $this; 159 | } 160 | 161 | $year = $this->year - $years; 162 | 163 | Field\Year::check($year); 164 | 165 | return new Year($year); 166 | } 167 | 168 | /** 169 | * Compares this year to another year. 170 | * 171 | * @param Year $that The year to compare to. 172 | * 173 | * @return int [-1, 0, 1] If this year is before, equal to, or after the given year. 174 | * 175 | * @psalm-return -1|0|1 176 | */ 177 | public function compareTo(Year $that): int 178 | { 179 | if ($this->year > $that->year) { 180 | return 1; 181 | } 182 | 183 | if ($this->year < $that->year) { 184 | return -1; 185 | } 186 | 187 | return 0; 188 | } 189 | 190 | /** 191 | * Checks if this year is equal to the given year. 192 | * 193 | * @param Year $that The year to compare to. 194 | * 195 | * @return bool True if this year is equal to the given year, false otherwise. 196 | */ 197 | public function isEqualTo(Year $that): bool 198 | { 199 | return $this->year === $that->year; 200 | } 201 | 202 | /** 203 | * Checks if this year is after the given year. 204 | * 205 | * @param Year $that The year to compare to. 206 | * 207 | * @return bool True if this year is after the given year, false otherwise. 208 | */ 209 | public function isAfter(Year $that): bool 210 | { 211 | return $this->year > $that->year; 212 | } 213 | 214 | /** 215 | * Checks if this year is before the given year. 216 | * 217 | * @param Year $that The year to compare to. 218 | * 219 | * @return bool True if this year is before the given year, false otherwise. 220 | */ 221 | public function isBefore(Year $that): bool 222 | { 223 | return $this->year < $that->year; 224 | } 225 | 226 | /** 227 | * Combines this year with a day-of-year to create a LocalDate. 228 | * 229 | * @param int $dayOfYear The day-of-year to use, from 1 to 366. 230 | * 231 | * @throws DateTimeException If the day-of-year is invalid for this year. 232 | */ 233 | public function atDay(int $dayOfYear): LocalDate 234 | { 235 | return LocalDate::ofYearDay($this->year, $dayOfYear); 236 | } 237 | 238 | /** 239 | * Combines this year with a month to create a YearMonth. 240 | */ 241 | public function atMonth(int|Month $month): YearMonth 242 | { 243 | if (is_int($month)) { 244 | Field\MonthOfYear::check($month); 245 | } 246 | 247 | return YearMonth::of($this->year, $month); 248 | } 249 | 250 | /** 251 | * Combines this Year with a MonthDay to create a LocalDate. 252 | * 253 | * A month-day of February 29th will be adjusted to February 28th 254 | * in the resulting date if the year is not a leap year. 255 | * 256 | * @param MonthDay $monthDay The month-day to use. 257 | */ 258 | public function atMonthDay(MonthDay $monthDay): LocalDate 259 | { 260 | return $monthDay->atYear($this->year); 261 | } 262 | 263 | /** 264 | * Returns LocalDateRange that contains all days of this year. 265 | */ 266 | public function toLocalDateRange(): LocalDateRange 267 | { 268 | return LocalDateRange::of( 269 | $this->atMonth(Month::JANUARY)->getFirstDay(), 270 | $this->atMonth(Month::DECEMBER)->getLastDay(), 271 | ); 272 | } 273 | 274 | /** 275 | * Serializes as a string using {@see Year::toISOString()}. 276 | * 277 | * @psalm-return non-empty-string 278 | */ 279 | public function jsonSerialize(): string 280 | { 281 | return $this->toISOString(); 282 | } 283 | 284 | /** 285 | * Returns the ISO 8601 representation of this year. 286 | * 287 | * @psalm-return non-empty-string 288 | */ 289 | public function toISOString(): string 290 | { 291 | // This code is optimized for high performance 292 | return $this->year < 1000 && $this->year > -1000 293 | ? ( 294 | $this->year < 0 295 | ? '-' . str_pad((string) -$this->year, 4, '0', STR_PAD_LEFT) 296 | : str_pad((string) $this->year, 4, '0', STR_PAD_LEFT) 297 | ) 298 | : (string) $this->year; 299 | } 300 | 301 | /** 302 | * {@see Year::toISOString()}. 303 | * 304 | * @psalm-return non-empty-string 305 | */ 306 | public function __toString(): string 307 | { 308 | return $this->toISOString(); 309 | } 310 | } 311 | -------------------------------------------------------------------------------- /src/Parser/IsoParsers.php: -------------------------------------------------------------------------------- 1 | appendCapturePattern(Year::PATTERN, Year::NAME) 43 | ->appendLiteral('-') 44 | ->appendCapturePattern(MonthOfYear::PATTERN, MonthOfYear::NAME) 45 | ->appendLiteral('-') 46 | ->appendCapturePattern(DayOfMonth::PATTERN, DayOfMonth::NAME) 47 | ->toParser(); 48 | } 49 | 50 | /** 51 | * Returns a parser for an ISO local time such as `10:15:30.123`. 52 | * 53 | * The second and fraction of second are optional. 54 | */ 55 | public static function localTime(): PatternParser 56 | { 57 | /** @var PatternParser|null $parser */ 58 | static $parser = null; 59 | 60 | return $parser ??= (new PatternParserBuilder()) 61 | ->appendCapturePattern(HourOfDay::PATTERN, HourOfDay::NAME) 62 | ->appendLiteral(':') 63 | ->appendCapturePattern(MinuteOfHour::PATTERN, MinuteOfHour::NAME) 64 | ->startOptional() 65 | ->appendLiteral(':') 66 | ->appendCapturePattern(SecondOfMinute::PATTERN, SecondOfMinute::NAME) 67 | ->startOptional() 68 | ->appendLiteral('.') 69 | ->appendCapturePattern(FractionOfSecond::PATTERN, FractionOfSecond::NAME) 70 | ->endOptional() 71 | ->endOptional() 72 | ->toParser(); 73 | } 74 | 75 | /** 76 | * Returns a parser for an ISO local date-time such as `2014-12-31T10:15`. 77 | * 78 | * The second and fraction of second are optional. 79 | */ 80 | public static function localDateTime(): PatternParser 81 | { 82 | /** @var PatternParser|null $parser */ 83 | static $parser = null; 84 | 85 | return $parser ??= (new PatternParserBuilder()) 86 | ->append(self::localDate()) 87 | ->appendLiteral('T') 88 | ->append(self::localTime()) 89 | ->toParser(); 90 | } 91 | 92 | /** 93 | * Returns a parser for a range of local dates such as `2014-01-05/2015-03-15`. 94 | */ 95 | public static function localDateRange(): PatternParser 96 | { 97 | /** @var PatternParser|null $parser */ 98 | static $parser = null; 99 | 100 | return $parser ??= (new PatternParserBuilder()) 101 | ->append(self::localDate()) 102 | ->appendLiteral('/') 103 | 104 | ->startGroup() 105 | ->startGroup() 106 | ->append(self::localDate()) 107 | ->endGroup() 108 | ->appendOr() 109 | ->startGroup() 110 | ->startOptional() 111 | ->appendCapturePattern(MonthOfYear::PATTERN, MonthOfYear::NAME) 112 | ->appendLiteral('-') 113 | ->endOptional() 114 | ->appendCapturePattern(DayOfMonth::PATTERN, DayOfMonth::NAME) 115 | ->endGroup() 116 | ->endGroup() 117 | 118 | ->toParser(); 119 | } 120 | 121 | /** 122 | * Returns a parser for a range of year-months such as `2014-01/2015-03`. 123 | * 124 | * Note that ISO 8601 does not seem to define a format for year-month ranges, but we're using the same format as 125 | * date ranges here. 126 | */ 127 | public static function yearMonthRange(): PatternParser 128 | { 129 | /** @var PatternParser|null $parser */ 130 | static $parser = null; 131 | 132 | return $parser ??= (new PatternParserBuilder()) 133 | ->append(self::yearMonth()) 134 | ->appendLiteral('/') 135 | 136 | ->startGroup() 137 | ->startGroup() 138 | ->append(self::yearMonth()) 139 | ->endGroup() 140 | ->appendOr() 141 | ->startGroup() 142 | ->appendCapturePattern(MonthOfYear::PATTERN, MonthOfYear::NAME) 143 | ->endGroup() 144 | ->endGroup() 145 | 146 | ->toParser(); 147 | } 148 | 149 | /** 150 | * Returns a parser for a year such as `2014`. 151 | */ 152 | public static function year(): PatternParser 153 | { 154 | /** @var PatternParser|null $parser */ 155 | static $parser = null; 156 | 157 | return $parser ??= (new PatternParserBuilder()) 158 | ->appendCapturePattern(Year::PATTERN, Year::NAME) 159 | ->toParser(); 160 | } 161 | 162 | /** 163 | * Returns a parser for a year-month such as `2014-12`. 164 | */ 165 | public static function yearMonth(): PatternParser 166 | { 167 | /** @var PatternParser|null $parser */ 168 | static $parser = null; 169 | 170 | return $parser ??= (new PatternParserBuilder()) 171 | ->appendCapturePattern(Year::PATTERN, Year::NAME) 172 | ->appendLiteral('-') 173 | ->appendCapturePattern(MonthOfYear::PATTERN, MonthOfYear::NAME) 174 | ->toParser(); 175 | } 176 | 177 | /** 178 | * Returns a parser for a year-week such as `2014-W15`. 179 | */ 180 | public static function yearWeek(): PatternParser 181 | { 182 | /** @var PatternParser|null $parser */ 183 | static $parser = null; 184 | 185 | return $parser ??= (new PatternParserBuilder()) 186 | ->appendCapturePattern(Year::PATTERN, Year::NAME) 187 | ->appendLiteral('-W') 188 | ->appendCapturePattern(WeekOfYear::PATTERN, WeekOfYear::NAME) 189 | ->toParser(); 190 | } 191 | 192 | /** 193 | * Returns a parser for a month-day such as `12-31`. 194 | */ 195 | public static function monthDay(): PatternParser 196 | { 197 | /** @var PatternParser|null $parser */ 198 | static $parser = null; 199 | 200 | return $parser ??= (new PatternParserBuilder()) 201 | ->appendLiteral('--') 202 | ->appendCapturePattern(MonthOfYear::PATTERN, MonthOfYear::NAME) 203 | ->appendLiteral('-') 204 | ->appendCapturePattern(DayOfMonth::PATTERN, DayOfMonth::NAME) 205 | ->toParser(); 206 | } 207 | 208 | /** 209 | * Returns a parser for a time-zone offset such as `Z`, `+01`, `+01:00`, `+01:00:00`. 210 | */ 211 | public static function timeZoneOffset(): PatternParser 212 | { 213 | /** @var PatternParser|null $parser */ 214 | static $parser = null; 215 | 216 | return $parser ??= (new PatternParserBuilder()) 217 | ->startGroup() 218 | ->appendCapturePattern('[Zz]', TimeZoneOffsetSign::NAME) 219 | ->appendOr() 220 | ->startGroup() 221 | ->appendCapturePattern('[\-\+]', TimeZoneOffsetSign::NAME) 222 | ->appendCapturePattern(TimeZoneOffsetHour::PATTERN, TimeZoneOffsetHour::NAME) 223 | ->startOptional() 224 | ->appendLiteral(':') 225 | ->appendCapturePattern(TimeZoneOffsetMinute::PATTERN, TimeZoneOffsetMinute::NAME) 226 | ->endOptional() 227 | ->startOptional() 228 | ->appendLiteral(':') 229 | ->appendCapturePattern(TimeZoneOffsetSecond::PATTERN, TimeZoneOffsetSecond::NAME) 230 | ->endOptional() 231 | ->endGroup() 232 | ->endGroup() 233 | ->toParser(); 234 | } 235 | 236 | /** 237 | * Returns a parser for a time-zone region such as `Europe/London`. 238 | */ 239 | public static function timeZoneRegion(): PatternParser 240 | { 241 | /** @var PatternParser|null $parser */ 242 | static $parser = null; 243 | 244 | return $parser ??= (new PatternParserBuilder()) 245 | ->appendCapturePattern(TimeZoneRegion::PATTERN, TimeZoneRegion::NAME) 246 | ->toParser(); 247 | } 248 | 249 | /** 250 | * Returns a parser for an offset date-time such as `2004-01-31T12:45:56+01:00`. 251 | */ 252 | public static function offsetDateTime(): PatternParser 253 | { 254 | /** @var PatternParser|null $parser */ 255 | static $parser = null; 256 | 257 | return $parser ??= (new PatternParserBuilder()) 258 | ->append(self::localDateTime()) 259 | ->append(self::timeZoneOffset()) 260 | ->toParser(); 261 | } 262 | 263 | /** 264 | * Returns a parser for a date-time with offset and zone such as `2011-12-03T10:15:30+01:00[Europe/Paris]. 265 | */ 266 | public static function zonedDateTime(): PatternParser 267 | { 268 | /** @var PatternParser|null $parser */ 269 | static $parser = null; 270 | 271 | return $parser ??= (new PatternParserBuilder()) 272 | ->append(self::offsetDateTime()) 273 | ->startOptional() 274 | ->appendLiteral('[') 275 | ->append(self::timeZoneRegion()) 276 | ->appendLiteral(']') 277 | ->endOptional() 278 | ->toParser(); 279 | } 280 | } 281 | -------------------------------------------------------------------------------- /src/YearMonth.php: -------------------------------------------------------------------------------- 1 | $month The month. 28 | */ 29 | private function __construct( 30 | private readonly int $year, 31 | private readonly int $month, 32 | ) { 33 | } 34 | 35 | /** 36 | * Obtains an instance of `YearMonth` from a year and month. 37 | * 38 | * @param int $year The year, from MIN_YEAR to MAX_YEAR. 39 | * 40 | * @throws DateTimeException 41 | */ 42 | public static function of(int $year, int|Month $month): YearMonth 43 | { 44 | Field\Year::check($year); 45 | 46 | if (is_int($month)) { 47 | Field\MonthOfYear::check($month); 48 | } else { 49 | $month = $month->value; 50 | } 51 | 52 | return new YearMonth($year, $month); 53 | } 54 | 55 | /** 56 | * @throws DateTimeException If the year-month is not valid. 57 | * @throws DateTimeParseException If required fields are missing from the result. 58 | */ 59 | public static function from(DateTimeParseResult $result): YearMonth 60 | { 61 | return YearMonth::of( 62 | (int) $result->getField(Field\Year::NAME), 63 | (int) $result->getField(Field\MonthOfYear::NAME), 64 | ); 65 | } 66 | 67 | /** 68 | * Obtains an instance of `YearMonth` from a text string. 69 | * 70 | * @param string $text The text to parse, such as `2007-12`. 71 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 72 | * 73 | * @throws DateTimeException If the date is not valid. 74 | * @throws DateTimeParseException If the text string does not follow the expected format. 75 | */ 76 | public static function parse(string $text, ?DateTimeParser $parser = null): YearMonth 77 | { 78 | if ($parser === null) { 79 | $parser = IsoParsers::yearMonth(); 80 | } 81 | 82 | return YearMonth::from($parser->parse($text)); 83 | } 84 | 85 | /** 86 | * Returns the current year-month in the given time-zone, according to the given clock. 87 | * 88 | * If no clock is provided, the system clock is used. 89 | */ 90 | public static function now(TimeZone $timeZone, ?Clock $clock = null): YearMonth 91 | { 92 | $localDate = LocalDate::now($timeZone, $clock); 93 | 94 | return new YearMonth($localDate->getYear(), $localDate->getMonthValue()); 95 | } 96 | 97 | public function getYear(): int 98 | { 99 | return $this->year; 100 | } 101 | 102 | /** 103 | * Returns the month-of-year as a Month enum. 104 | */ 105 | public function getMonth(): Month 106 | { 107 | return Month::from($this->month); 108 | } 109 | 110 | /** 111 | * Returns the month-of-year value from 1 to 12. 112 | * 113 | * @return int<1, 12> 114 | */ 115 | public function getMonthValue(): int 116 | { 117 | return $this->month; 118 | } 119 | 120 | /** 121 | * Returns whether the year is a leap year. 122 | */ 123 | public function isLeapYear(): bool 124 | { 125 | return Year::of($this->year)->isLeap(); 126 | } 127 | 128 | /** 129 | * Returns the length of the month in days, taking account of the year. 130 | * 131 | * @return int<28, 31> 132 | */ 133 | public function getLengthOfMonth(): int 134 | { 135 | return Month::from($this->month)->getLength($this->isLeapYear()); 136 | } 137 | 138 | /** 139 | * Returns the length of the year in days, either 365 or 366. 140 | */ 141 | public function getLengthOfYear(): int 142 | { 143 | return $this->isLeapYear() ? 366 : 365; 144 | } 145 | 146 | /** 147 | * @return int [-1,0,1] If this year-month is before, on, or after the given year-month. 148 | * 149 | * @psalm-return -1|0|1 150 | */ 151 | public function compareTo(YearMonth $that): int 152 | { 153 | if ($this->year < $that->year) { 154 | return -1; 155 | } 156 | if ($this->year > $that->year) { 157 | return 1; 158 | } 159 | if ($this->month < $that->month) { 160 | return -1; 161 | } 162 | if ($this->month > $that->month) { 163 | return 1; 164 | } 165 | 166 | return 0; 167 | } 168 | 169 | public function isEqualTo(YearMonth $that): bool 170 | { 171 | return $this->compareTo($that) === 0; 172 | } 173 | 174 | public function isBefore(YearMonth $that): bool 175 | { 176 | return $this->compareTo($that) === -1; 177 | } 178 | 179 | public function isBeforeOrEqualTo(YearMonth $that): bool 180 | { 181 | return $this->compareTo($that) <= 0; 182 | } 183 | 184 | public function isAfter(YearMonth $that): bool 185 | { 186 | return $this->compareTo($that) === 1; 187 | } 188 | 189 | public function isAfterOrEqualTo(YearMonth $that): bool 190 | { 191 | return $this->compareTo($that) >= 0; 192 | } 193 | 194 | /** 195 | * Returns a copy of this YearMonth with the year altered. 196 | * 197 | * @throws DateTimeException If the year is not valid. 198 | */ 199 | public function withYear(int $year): YearMonth 200 | { 201 | if ($year === $this->year) { 202 | return $this; 203 | } 204 | 205 | Field\Year::check($year); 206 | 207 | return new YearMonth($year, $this->month); 208 | } 209 | 210 | /** 211 | * Returns a copy of this YearMonth with the month-of-year altered. 212 | */ 213 | public function withMonth(int|Month $month): YearMonth 214 | { 215 | if (is_int($month)) { 216 | Field\MonthOfYear::check($month); 217 | } else { 218 | $month = $month->value; 219 | } 220 | 221 | if ($month === $this->month) { 222 | return $this; 223 | } 224 | 225 | return new YearMonth($this->year, $month); 226 | } 227 | 228 | public function getFirstDay(): LocalDate 229 | { 230 | return $this->atDay(1); 231 | } 232 | 233 | public function getLastDay(): LocalDate 234 | { 235 | return $this->atDay($this->getLengthOfMonth()); 236 | } 237 | 238 | /** 239 | * Combines this year-month with a day-of-month to create a LocalDate. 240 | * 241 | * @param int $day The day-of-month to use, valid for the year-month. 242 | * 243 | * @return LocalDate The date formed from this year-month and the specified day. 244 | * 245 | * @throws DateTimeException If the day is not valid for this year-month. 246 | */ 247 | public function atDay(int $day): LocalDate 248 | { 249 | return LocalDate::of($this->year, $this->month, $day); 250 | } 251 | 252 | /** 253 | * Returns a copy of this YearMonth with the specified period in years added. 254 | */ 255 | public function plusYears(int $years): YearMonth 256 | { 257 | if ($years === 0) { 258 | return $this; 259 | } 260 | 261 | return $this->withYear($this->year + $years); 262 | } 263 | 264 | /** 265 | * Returns a copy of this YearMonth with the specified period in months added. 266 | */ 267 | public function plusMonths(int $months): YearMonth 268 | { 269 | if ($months === 0) { 270 | return $this; 271 | } 272 | 273 | $month = $this->month + $months - 1; 274 | 275 | $yearDiff = Math::floorDiv($month, 12); 276 | 277 | /** @var int<1, 12> $month */ 278 | $month = Math::floorMod($month, 12) + 1; 279 | 280 | $year = $this->year + $yearDiff; 281 | 282 | return new YearMonth($year, $month); 283 | } 284 | 285 | /** 286 | * Returns a copy of this YearMonth with the specified period in years subtracted. 287 | */ 288 | public function minusYears(int $years): YearMonth 289 | { 290 | return $this->plusYears(-$years); 291 | } 292 | 293 | /** 294 | * Returns a copy of this YearMonth with the specified period in months subtracted. 295 | */ 296 | public function minusMonths(int $months): YearMonth 297 | { 298 | return $this->plusMonths(-$months); 299 | } 300 | 301 | /** 302 | * Returns LocalDateRange that contains all days of this year and month. 303 | */ 304 | public function toLocalDateRange(): LocalDateRange 305 | { 306 | return LocalDateRange::of($this->getFirstDay(), $this->getLastDay()); 307 | } 308 | 309 | /** 310 | * Serializes as a string using {@see YearMonth::toISOString()}. 311 | * 312 | * @psalm-return non-empty-string 313 | */ 314 | public function jsonSerialize(): string 315 | { 316 | return $this->toISOString(); 317 | } 318 | 319 | /** 320 | * Returns the ISO 8601 representation of this year-month. 321 | * 322 | * @psalm-return non-empty-string 323 | */ 324 | public function toISOString(): string 325 | { 326 | // This code is optimized for high performance 327 | return ($this->year < 1000 && $this->year > -1000 328 | ? ( 329 | $this->year < 0 330 | ? '-' . str_pad((string) -$this->year, 4, '0', STR_PAD_LEFT) 331 | : str_pad((string) $this->year, 4, '0', STR_PAD_LEFT) 332 | ) 333 | : $this->year 334 | ) 335 | . '-' 336 | . ($this->month < 10 ? '0' . $this->month : $this->month); 337 | } 338 | 339 | /** 340 | * {@see YearMonth::toISOString()}. 341 | * 342 | * @psalm-return non-empty-string 343 | */ 344 | public function __toString(): string 345 | { 346 | return $this->toISOString(); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /src/YearWeek.php: -------------------------------------------------------------------------------- 1 | getField(Field\Year::NAME), 58 | (int) $result->getField(Field\WeekOfYear::NAME), 59 | ); 60 | } 61 | 62 | /** 63 | * Obtains an instance of `YearWeek` from a text string. 64 | * 65 | * @param string $text The text to parse, such as `2007-W48`. 66 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 67 | * 68 | * @throws DateTimeException If the year-week is not valid. 69 | * @throws DateTimeParseException If the text string does not follow the expected format. 70 | */ 71 | public static function parse(string $text, ?DateTimeParser $parser = null): YearWeek 72 | { 73 | if ($parser === null) { 74 | $parser = IsoParsers::yearWeek(); 75 | } 76 | 77 | return YearWeek::from($parser->parse($text)); 78 | } 79 | 80 | public static function now(TimeZone $timeZone, ?Clock $clock = null): YearWeek 81 | { 82 | return LocalDate::now($timeZone, $clock)->getYearWeek(); 83 | } 84 | 85 | public function getYear(): int 86 | { 87 | return $this->year; 88 | } 89 | 90 | public function getWeek(): int 91 | { 92 | return $this->week; 93 | } 94 | 95 | /** 96 | * @return int [-1,0,1] If this year-week is before, on, or after the given year-week. 97 | * 98 | * @psalm-return -1|0|1 99 | */ 100 | public function compareTo(YearWeek $that): int 101 | { 102 | if ($this->year < $that->year) { 103 | return -1; 104 | } 105 | if ($this->year > $that->year) { 106 | return 1; 107 | } 108 | if ($this->week < $that->week) { 109 | return -1; 110 | } 111 | if ($this->week > $that->week) { 112 | return 1; 113 | } 114 | 115 | return 0; 116 | } 117 | 118 | public function isEqualTo(YearWeek $that): bool 119 | { 120 | return $this->compareTo($that) === 0; 121 | } 122 | 123 | public function isBefore(YearWeek $that): bool 124 | { 125 | return $this->compareTo($that) === -1; 126 | } 127 | 128 | public function isBeforeOrEqualTo(YearWeek $that): bool 129 | { 130 | return $this->compareTo($that) <= 0; 131 | } 132 | 133 | public function isAfter(YearWeek $that): bool 134 | { 135 | return $this->compareTo($that) === 1; 136 | } 137 | 138 | public function isAfterOrEqualTo(YearWeek $that): bool 139 | { 140 | return $this->compareTo($that) >= 0; 141 | } 142 | 143 | /** 144 | * Returns a copy of this YearWeek with the year altered. 145 | * 146 | * If the week is 53 and the new year does not have 53 weeks, the week will be adjusted to be 52. 147 | * 148 | * @throws DateTimeException If the year is not valid. 149 | */ 150 | public function withYear(int $year): YearWeek 151 | { 152 | if ($year === $this->year) { 153 | return $this; 154 | } 155 | 156 | Field\Year::check($year); 157 | 158 | $week = $this->week; 159 | 160 | if ($week === 53 && ! Field\WeekOfYear::is53WeekYear($year)) { 161 | $week = 52; 162 | } 163 | 164 | return new YearWeek($year, $week); 165 | } 166 | 167 | /** 168 | * Returns a copy of this YearWeek with the week altered. 169 | * 170 | * If the new week is 53 and the year does not have 53 weeks, week one of the following year is selected. 171 | * 172 | * @throws DateTimeException If the week is not valid. 173 | */ 174 | public function withWeek(int $week): YearWeek 175 | { 176 | if ($week === $this->week) { 177 | return $this; 178 | } 179 | 180 | Field\WeekOfYear::check($week); 181 | 182 | $year = $this->year; 183 | 184 | if ($week === 53 && ! Field\WeekOfYear::is53WeekYear($year)) { 185 | $year++; 186 | $week = 1; 187 | } 188 | 189 | return new YearWeek($year, $week); 190 | } 191 | 192 | /** 193 | * Combines this year-week with a day-of-week to create a LocalDate. 194 | */ 195 | public function atDay(DayOfWeek|int $dayOfWeek): LocalDate 196 | { 197 | if (is_int($dayOfWeek)) { 198 | Field\DayOfWeek::check($dayOfWeek); 199 | } else { 200 | $dayOfWeek = $dayOfWeek->value; 201 | } 202 | 203 | $correction = LocalDate::of($this->year, Month::JANUARY, 4)->getDayOfWeek()->value + 3; 204 | $dayOfYear = $this->week * 7 + $dayOfWeek - $correction; 205 | $maxDaysOfYear = Field\Year::isLeap($this->year) ? 366 : 365; 206 | 207 | if ($dayOfYear > $maxDaysOfYear) { 208 | return LocalDate::ofYearDay($this->year + 1, $dayOfYear - $maxDaysOfYear); 209 | } 210 | 211 | if ($dayOfYear > 0) { 212 | return LocalDate::ofYearDay($this->year, $dayOfYear); 213 | } 214 | 215 | $daysOfPreviousYear = Field\Year::isLeap($this->year - 1) ? 366 : 365; 216 | 217 | return LocalDate::ofYearDay($this->year - 1, $daysOfPreviousYear + $dayOfYear); 218 | } 219 | 220 | /** 221 | * Returns the first day of this week. 222 | */ 223 | public function getFirstDay(): LocalDate 224 | { 225 | return $this->atDay(DayOfWeek::MONDAY); 226 | } 227 | 228 | /** 229 | * Returns the last day of this week. 230 | */ 231 | public function getLastDay(): LocalDate 232 | { 233 | return $this->atDay(DayOfWeek::SUNDAY); 234 | } 235 | 236 | /** 237 | * Returns a copy of this YearWeek with the specified period in years added. 238 | * 239 | * If the week is 53 and the new year does not have 53 weeks, the week will be adjusted to be 52. 240 | */ 241 | public function plusYears(int $years): YearWeek 242 | { 243 | if ($years === 0) { 244 | return $this; 245 | } 246 | 247 | return $this->withYear($this->year + $years); 248 | } 249 | 250 | /** 251 | * Returns a copy of this YearWeek with the specified period in weeks added. 252 | */ 253 | public function plusWeeks(int $weeks): YearWeek 254 | { 255 | if ($weeks === 0) { 256 | return $this; 257 | } 258 | 259 | $mondayOfWeek = $this->atDay(DayOfWeek::MONDAY)->plusWeeks($weeks); 260 | 261 | return $mondayOfWeek->getYearWeek(); 262 | } 263 | 264 | /** 265 | * Returns a copy of this YearWeek with the specified period in years subtracted. 266 | * 267 | * If the week is 53 and the new year does not have 53 weeks, the week will be adjusted to be 52. 268 | */ 269 | public function minusYears(int $years): YearWeek 270 | { 271 | return $this->plusYears(-$years); 272 | } 273 | 274 | /** 275 | * Returns a copy of this YearWeek with the specified period in weeks subtracted. 276 | */ 277 | public function minusWeeks(int $weeks): YearWeek 278 | { 279 | return $this->plusWeeks(-$weeks); 280 | } 281 | 282 | /** 283 | * Returns whether this year has 53 weeks. 284 | */ 285 | public function is53WeekYear(): bool 286 | { 287 | return Field\WeekOfYear::is53WeekYear($this->year); 288 | } 289 | 290 | /** 291 | * Returns LocalDateRange that contains all days of this year week. 292 | */ 293 | public function toLocalDateRange(): LocalDateRange 294 | { 295 | return LocalDateRange::of($this->getFirstDay(), $this->getLastDay()); 296 | } 297 | 298 | /** 299 | * Serializes as a string using {@see YearWeek::toISOString()}. 300 | * 301 | * @psalm-return non-empty-string 302 | */ 303 | public function jsonSerialize(): string 304 | { 305 | return $this->toISOString(); 306 | } 307 | 308 | /** 309 | * Returns the ISO 8601 representation of this year-week. 310 | * 311 | * @psalm-return non-empty-string 312 | */ 313 | public function toISOString(): string 314 | { 315 | // This code is optimized for high performance 316 | return ($this->year < 1000 && $this->year > -1000 317 | ? ( 318 | $this->year < 0 319 | ? '-' . str_pad((string) -$this->year, 4, '0', STR_PAD_LEFT) 320 | : str_pad((string) $this->year, 4, '0', STR_PAD_LEFT) 321 | ) 322 | : $this->year 323 | ) 324 | . '-W' 325 | . ($this->week < 10 ? '0' . $this->week : $this->week); 326 | } 327 | 328 | /** 329 | * {@see YearWeek::toISOString()}. 330 | * 331 | * @psalm-return non-empty-string 332 | */ 333 | public function __toString(): string 334 | { 335 | return $this->toISOString(); 336 | } 337 | } 338 | -------------------------------------------------------------------------------- /src/Instant.php: -------------------------------------------------------------------------------- 1 | getTime(); 84 | } 85 | 86 | /** 87 | * Returns the minimum supported instant. 88 | * 89 | * This could be used by an application as a "far past" instant. 90 | */ 91 | public static function min(): Instant 92 | { 93 | /** @var Instant|null $min */ 94 | static $min = null; 95 | 96 | return $min ??= new Instant(PHP_INT_MIN, 0); 97 | } 98 | 99 | /** 100 | * Returns the maximum supported instant. 101 | * 102 | * This could be used by an application as a "far future" instant. 103 | */ 104 | public static function max(): Instant 105 | { 106 | /** @var Instant|null $max */ 107 | static $max = null; 108 | 109 | return $max ??= new Instant(PHP_INT_MAX, 999_999_999); 110 | } 111 | 112 | public function plus(Duration $duration): Instant 113 | { 114 | if ($duration->isZero()) { 115 | return $this; 116 | } 117 | 118 | $seconds = $this->epochSecond + $duration->getSeconds(); 119 | $nanos = $this->nano + $duration->getNanos(); 120 | 121 | return Instant::of($seconds, $nanos); 122 | } 123 | 124 | public function minus(Duration $duration): Instant 125 | { 126 | if ($duration->isZero()) { 127 | return $this; 128 | } 129 | 130 | return $this->plus($duration->negated()); 131 | } 132 | 133 | public function plusSeconds(int $seconds): Instant 134 | { 135 | if ($seconds === 0) { 136 | return $this; 137 | } 138 | 139 | return new Instant($this->epochSecond + $seconds, $this->nano); 140 | } 141 | 142 | public function minusSeconds(int $seconds): Instant 143 | { 144 | return $this->plusSeconds(-$seconds); 145 | } 146 | 147 | public function plusMinutes(int $minutes): Instant 148 | { 149 | return $this->plusSeconds($minutes * LocalTime::SECONDS_PER_MINUTE); 150 | } 151 | 152 | public function minusMinutes(int $minutes): Instant 153 | { 154 | return $this->plusMinutes(-$minutes); 155 | } 156 | 157 | public function plusHours(int $hours): Instant 158 | { 159 | return $this->plusSeconds($hours * LocalTime::SECONDS_PER_HOUR); 160 | } 161 | 162 | public function minusHours(int $hours): Instant 163 | { 164 | return $this->plusHours(-$hours); 165 | } 166 | 167 | public function plusDays(int $days): Instant 168 | { 169 | return $this->plusSeconds($days * LocalTime::SECONDS_PER_DAY); 170 | } 171 | 172 | /** 173 | * Returns a copy of this Instant with the epoch second altered. 174 | */ 175 | public function withEpochSecond(int $epochSecond): Instant 176 | { 177 | if ($epochSecond === $this->epochSecond) { 178 | return $this; 179 | } 180 | 181 | return new Instant($epochSecond, $this->nano); 182 | } 183 | 184 | /** 185 | * Returns a copy of this Instant with the nano-of-second altered. 186 | * 187 | * @throws DateTimeException If the nano-of-second if not valid. 188 | */ 189 | public function withNano(int $nano): Instant 190 | { 191 | if ($nano === $this->nano) { 192 | return $this; 193 | } 194 | 195 | Field\NanoOfSecond::check($nano); 196 | 197 | return new Instant($this->epochSecond, $nano); 198 | } 199 | 200 | public function minusDays(int $days): Instant 201 | { 202 | return $this->plusDays(-$days); 203 | } 204 | 205 | public function getEpochSecond(): int 206 | { 207 | return $this->epochSecond; 208 | } 209 | 210 | public function getNano(): int 211 | { 212 | return $this->nano; 213 | } 214 | 215 | /** 216 | * Compares this instant with another. 217 | * 218 | * @return int [-1,0,1] If this instant is before, on, or after the given instant. 219 | * 220 | * @psalm-return -1|0|1 221 | */ 222 | public function compareTo(Instant $that): int 223 | { 224 | $seconds = $this->getEpochSecond() - $that->getEpochSecond(); 225 | 226 | if ($seconds !== 0) { 227 | return $seconds > 0 ? 1 : -1; 228 | } 229 | 230 | $nanos = $this->getNano() - $that->getNano(); 231 | 232 | if ($nanos !== 0) { 233 | return $nanos > 0 ? 1 : -1; 234 | } 235 | 236 | return 0; 237 | } 238 | 239 | /** 240 | * Returns whether this instant equals another. 241 | */ 242 | public function isEqualTo(Instant $that): bool 243 | { 244 | return $this->compareTo($that) === 0; 245 | } 246 | 247 | /** 248 | * Returns whether this instant is after another. 249 | */ 250 | public function isAfter(Instant $that): bool 251 | { 252 | return $this->compareTo($that) === 1; 253 | } 254 | 255 | /** 256 | * Returns whether this instant is after or equal to another. 257 | */ 258 | public function isAfterOrEqualTo(Instant $that): bool 259 | { 260 | return $this->compareTo($that) >= 0; 261 | } 262 | 263 | /** 264 | * Returns whether this instant is before another. 265 | */ 266 | public function isBefore(Instant $that): bool 267 | { 268 | return $this->compareTo($that) === -1; 269 | } 270 | 271 | /** 272 | * Returns whether this instant is before or equal to another. 273 | */ 274 | public function isBeforeOrEqualTo(Instant $that): bool 275 | { 276 | return $this->compareTo($that) <= 0; 277 | } 278 | 279 | public function isBetweenInclusive(Instant $from, Instant $to): bool 280 | { 281 | return $this->isAfterOrEqualTo($from) && $this->isBeforeOrEqualTo($to); 282 | } 283 | 284 | public function isBetweenExclusive(Instant $from, Instant $to): bool 285 | { 286 | return $this->isAfter($from) && $this->isBefore($to); 287 | } 288 | 289 | /** 290 | * Returns whether this instant is in the future, according to the given clock. 291 | * 292 | * If no clock is provided, the system clock is used. 293 | */ 294 | public function isFuture(?Clock $clock = null): bool 295 | { 296 | return $this->isAfter(Instant::now($clock)); 297 | } 298 | 299 | /** 300 | * Returns whether this instant is in the past, according to the given clock. 301 | * 302 | * If no clock is provided, the system clock is used. 303 | */ 304 | public function isPast(?Clock $clock = null): bool 305 | { 306 | return $this->isBefore(Instant::now($clock)); 307 | } 308 | 309 | /** 310 | * Returns a ZonedDateTime formed from this instant and the specified time-zone. 311 | */ 312 | public function atTimeZone(TimeZone $timeZone): ZonedDateTime 313 | { 314 | return ZonedDateTime::ofInstant($this, $timeZone); 315 | } 316 | 317 | /** 318 | * Returns an Interval from this Instant (inclusive) to the given one (exclusive). 319 | * 320 | * @throws DateTimeException If the given Instant is before this Instant. 321 | */ 322 | public function getIntervalTo(Instant $that): Interval 323 | { 324 | return Interval::of($this, $that); 325 | } 326 | 327 | /** 328 | * Returns a decimal representation of the timestamp represented by this instant. 329 | * 330 | * The output does not have trailing decimal zeros. 331 | * 332 | * Examples: `123456789`, `123456789.5`, `123456789.000000001`. 333 | */ 334 | public function toDecimal(): string 335 | { 336 | $result = (string) $this->epochSecond; 337 | 338 | if ($this->nano !== 0) { 339 | $nano = (string) $this->nano; 340 | $nano = str_pad($nano, 9, '0', STR_PAD_LEFT); 341 | $nano = rtrim($nano, '0'); 342 | 343 | $result .= '.' . $nano; 344 | } 345 | 346 | return $result; 347 | } 348 | 349 | /** 350 | * Serializes as a string using {@see Instant::toISOString()}. 351 | * 352 | * @psalm-return non-empty-string 353 | */ 354 | public function jsonSerialize(): string 355 | { 356 | return $this->toISOString(); 357 | } 358 | 359 | /** 360 | * Returns the ISO 8601 representation of this instant. 361 | * 362 | * @psalm-return non-empty-string 363 | */ 364 | public function toISOString(): string 365 | { 366 | return (string) ZonedDateTime::ofInstant($this, TimeZone::utc()); 367 | } 368 | 369 | /** 370 | * {@see Instant::toISOString()}. 371 | * 372 | * @psalm-return non-empty-string 373 | */ 374 | public function __toString(): string 375 | { 376 | return $this->toISOString(); 377 | } 378 | } 379 | -------------------------------------------------------------------------------- /src/Period.php: -------------------------------------------------------------------------------- 1 | h !== 0) { 142 | throw new DateTimeException('Cannot create a Period from a DateInterval with a non-zero hour.'); 143 | } 144 | 145 | if ($dateInterval->i !== 0) { 146 | throw new DateTimeException('Cannot create a Period from a DateInterval with a non-zero minute.'); 147 | } 148 | 149 | if ($dateInterval->s !== 0) { 150 | throw new DateTimeException('Cannot create a Period from a DateInterval with a non-zero second.'); 151 | } 152 | 153 | if ($dateInterval->f !== 0.0) { 154 | throw new DateTimeException('Cannot create a Period from a DateInterval with a non-zero microsecond.'); 155 | } 156 | 157 | $years = $dateInterval->y; 158 | $months = $dateInterval->m; 159 | $days = $dateInterval->d; 160 | 161 | if ($dateInterval->invert === 1) { 162 | $years = -$years; 163 | $months = -$months; 164 | $days = -$days; 165 | } 166 | 167 | return new Period($years, $months, $days); 168 | } 169 | 170 | /** 171 | * Returns a Period consisting of the number of years, months, and days between two dates. 172 | * 173 | * The start date is included, but the end date is not. 174 | * The period is calculated by removing complete months, then calculating 175 | * the remaining number of days, adjusting to ensure that both have the same sign. 176 | * The number of months is then split into years and months based on a 12 month year. 177 | * A month is considered if the end day-of-month is greater than or equal to the start day-of-month. 178 | * 179 | * For example, from `2010-01-15` to `2011-03-18` is one year, two months and three days. 180 | * 181 | * The result of this method can be a negative period if the end is before the start. 182 | * The negative sign will be the same in each of year, month and day. 183 | */ 184 | public static function between(LocalDate $startInclusive, LocalDate $endExclusive): Period 185 | { 186 | return $startInclusive->until($endExclusive); 187 | } 188 | 189 | public function getYears(): int 190 | { 191 | return $this->years; 192 | } 193 | 194 | public function getMonths(): int 195 | { 196 | return $this->months; 197 | } 198 | 199 | public function getDays(): int 200 | { 201 | return $this->days; 202 | } 203 | 204 | public function withYears(int $years): Period 205 | { 206 | if ($years === $this->years) { 207 | return $this; 208 | } 209 | 210 | return new Period($years, $this->months, $this->days); 211 | } 212 | 213 | public function withMonths(int $months): Period 214 | { 215 | if ($months === $this->months) { 216 | return $this; 217 | } 218 | 219 | return new Period($this->years, $months, $this->days); 220 | } 221 | 222 | public function withDays(int $days): Period 223 | { 224 | if ($days === $this->days) { 225 | return $this; 226 | } 227 | 228 | return new Period($this->years, $this->months, $days); 229 | } 230 | 231 | public function plusYears(int $years): Period 232 | { 233 | if ($years === 0) { 234 | return $this; 235 | } 236 | 237 | return new Period($this->years + $years, $this->months, $this->days); 238 | } 239 | 240 | public function plusMonths(int $months): Period 241 | { 242 | if ($months === 0) { 243 | return $this; 244 | } 245 | 246 | return new Period($this->years, $this->months + $months, $this->days); 247 | } 248 | 249 | public function plusDays(int $days): Period 250 | { 251 | if ($days === 0) { 252 | return $this; 253 | } 254 | 255 | return new Period($this->years, $this->months, $this->days + $days); 256 | } 257 | 258 | public function minusYears(int $years): Period 259 | { 260 | if ($years === 0) { 261 | return $this; 262 | } 263 | 264 | return new Period($this->years - $years, $this->months, $this->days); 265 | } 266 | 267 | public function minusMonths(int $months): Period 268 | { 269 | if ($months === 0) { 270 | return $this; 271 | } 272 | 273 | return new Period($this->years, $this->months - $months, $this->days); 274 | } 275 | 276 | public function minusDays(int $days): Period 277 | { 278 | if ($days === 0) { 279 | return $this; 280 | } 281 | 282 | return new Period($this->years, $this->months, $this->days - $days); 283 | } 284 | 285 | /** 286 | * Returns a new Period with each value multiplied by the given scalar. 287 | */ 288 | public function multipliedBy(int $scalar): Period 289 | { 290 | if ($scalar === 1) { 291 | return $this; 292 | } 293 | 294 | return new Period( 295 | $this->years * $scalar, 296 | $this->months * $scalar, 297 | $this->days * $scalar, 298 | ); 299 | } 300 | 301 | /** 302 | * Returns a new instance with each amount in this Period negated. 303 | */ 304 | public function negated(): Period 305 | { 306 | if ($this->isZero()) { 307 | return $this; 308 | } 309 | 310 | return new Period( 311 | -$this->years, 312 | -$this->months, 313 | -$this->days, 314 | ); 315 | } 316 | 317 | /** 318 | * Returns a copy of this Period with the years and months normalized. 319 | * 320 | * This normalizes the years and months units, leaving the days unit unchanged. 321 | * The months unit is adjusted to have an absolute value less than 12, 322 | * with the years unit being adjusted to compensate. For example, a period of 323 | * "1 year and 15 months" will be normalized to "2 years and 3 months". 324 | * 325 | * The sign of the years and months units will be the same after normalization. 326 | * For example, a period of "1 year and -25 months" will be normalized to 327 | * "-1 year and -1 month". 328 | */ 329 | public function normalized(): Period 330 | { 331 | $totalMonths = $this->years * LocalTime::MONTHS_PER_YEAR + $this->months; 332 | 333 | $splitYears = intdiv($totalMonths, 12); 334 | $splitMonths = $totalMonths % 12; 335 | 336 | if ($splitYears === $this->years || $splitMonths === $this->months) { 337 | return $this; 338 | } 339 | 340 | return new Period($splitYears, $splitMonths, $this->days); 341 | } 342 | 343 | public function isZero(): bool 344 | { 345 | return $this->years === 0 && $this->months === 0 && $this->days === 0; 346 | } 347 | 348 | public function isEqualTo(Period $that): bool 349 | { 350 | return $this->years === $that->years 351 | && $this->months === $that->months 352 | && $this->days === $that->days; 353 | } 354 | 355 | /** 356 | * Returns a native DateInterval object equivalent to this Period. 357 | * 358 | * We cannot use the constructor with the output of __toString(), 359 | * as it does not support negative values. 360 | */ 361 | public function toNativeDateInterval(): DateInterval 362 | { 363 | $nativeDateInterval = DateInterval::createFromDateString(sprintf( 364 | '%d years %d months %d days', 365 | $this->years, 366 | $this->months, 367 | $this->days, 368 | )); 369 | 370 | assert($nativeDateInterval !== false); 371 | 372 | return $nativeDateInterval; 373 | } 374 | 375 | /** 376 | * Serializes as a string using {@see Period::toISOString()}. 377 | * 378 | * @psalm-return non-empty-string 379 | */ 380 | public function jsonSerialize(): string 381 | { 382 | return $this->toISOString(); 383 | } 384 | 385 | /** 386 | * Returns the ISO 8601 representation of this period. 387 | * 388 | * @psalm-return non-empty-string 389 | */ 390 | public function toISOString(): string 391 | { 392 | if ($this->isZero()) { 393 | return 'P0D'; 394 | } 395 | 396 | $string = 'P'; 397 | 398 | if ($this->years !== 0) { 399 | $string .= $this->years . 'Y'; 400 | } 401 | if ($this->months !== 0) { 402 | $string .= $this->months . 'M'; 403 | } 404 | if ($this->days !== 0) { 405 | $string .= $this->days . 'D'; 406 | } 407 | 408 | return $string; 409 | } 410 | 411 | /** 412 | * {@see Period::toISOString()}. 413 | * 414 | * @psalm-return non-empty-string 415 | */ 416 | public function __toString(): string 417 | { 418 | return $this->toISOString(); 419 | } 420 | } 421 | -------------------------------------------------------------------------------- /src/LocalTime.php: -------------------------------------------------------------------------------- 1 | getField(HourOfDay::NAME); 108 | $minute = $result->getField(MinuteOfHour::NAME); 109 | $second = $result->getOptionalField(SecondOfMinute::NAME); 110 | $fraction = $result->getOptionalField(Field\FractionOfSecond::NAME); 111 | 112 | $nano = str_pad($fraction, 9, '0'); 113 | 114 | return LocalTime::of((int) $hour, (int) $minute, (int) $second, (int) $nano); 115 | } 116 | 117 | /** 118 | * Obtains an instance of `LocalTime` from a text string. 119 | * 120 | * @param string $text The text to parse, such as `10:15`. 121 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 122 | * 123 | * @throws DateTimeException If the time is not valid. 124 | * @throws DateTimeParseException If the text string does not follow the expected format. 125 | */ 126 | public static function parse(string $text, ?DateTimeParser $parser = null): LocalTime 127 | { 128 | if ($parser === null) { 129 | $parser = IsoParsers::localTime(); 130 | } 131 | 132 | return LocalTime::from($parser->parse($text)); 133 | } 134 | 135 | /** 136 | * Creates a LocalTime from a native DateTime or DateTimeImmutable object. 137 | */ 138 | public static function fromNativeDateTime(DateTimeInterface $dateTime): LocalTime 139 | { 140 | return new LocalTime( 141 | (int) $dateTime->format('G'), 142 | (int) $dateTime->format('i'), 143 | (int) $dateTime->format('s'), 144 | 1000 * (int) $dateTime->format('u'), 145 | ); 146 | } 147 | 148 | /** 149 | * Returns the current local time in the given time-zone, according to the given clock. 150 | * 151 | * If no clock is provided, the system clock is used. 152 | */ 153 | public static function now(TimeZone $timeZone, ?Clock $clock = null): LocalTime 154 | { 155 | return ZonedDateTime::now($timeZone, $clock)->getTime(); 156 | } 157 | 158 | public static function midnight(): LocalTime 159 | { 160 | return self::min(); 161 | } 162 | 163 | public static function noon(): LocalTime 164 | { 165 | /** @var LocalTime|null $noon */ 166 | static $noon = null; 167 | 168 | return $noon ??= new LocalTime(12, 0, 0, 0); 169 | } 170 | 171 | /** 172 | * Returns the smallest possible value for LocalTime. 173 | */ 174 | public static function min(): LocalTime 175 | { 176 | /** @var LocalTime|null $min */ 177 | static $min = null; 178 | 179 | return $min ??= new LocalTime(0, 0, 0, 0); 180 | } 181 | 182 | /** 183 | * Returns the highest possible value for LocalTime. 184 | */ 185 | public static function max(): LocalTime 186 | { 187 | /** @var LocalTime|null $max */ 188 | static $max = null; 189 | 190 | return $max ??= new LocalTime(23, 59, 59, 999_999_999); 191 | } 192 | 193 | /** 194 | * Returns the smallest LocalTime among the given values. 195 | * 196 | * @param LocalTime ...$times The LocalTime objects to compare. 197 | * 198 | * @return LocalTime The earliest LocalTime object. 199 | * 200 | * @throws DateTimeException If the array is empty. 201 | */ 202 | public static function minOf(LocalTime ...$times): LocalTime 203 | { 204 | if ($times === []) { 205 | throw new DateTimeException(__METHOD__ . ' does not accept less than 1 parameter.'); 206 | } 207 | 208 | $min = null; 209 | 210 | foreach ($times as $time) { 211 | if ($min === null || $time->isBefore($min)) { 212 | $min = $time; 213 | } 214 | } 215 | 216 | return $min; 217 | } 218 | 219 | /** 220 | * Returns the highest LocalTime among the given values. 221 | * 222 | * @param LocalTime ...$times The LocalTime objects to compare. 223 | * 224 | * @return LocalTime The latest LocalTime object. 225 | * 226 | * @throws DateTimeException If the array is empty. 227 | */ 228 | public static function maxOf(LocalTime ...$times): LocalTime 229 | { 230 | if ($times === []) { 231 | throw new DateTimeException(__METHOD__ . ' does not accept less than 1 parameter.'); 232 | } 233 | 234 | $max = null; 235 | 236 | foreach ($times as $time) { 237 | if ($max === null || $time->isAfter($max)) { 238 | $max = $time; 239 | } 240 | } 241 | 242 | return $max; 243 | } 244 | 245 | public function getHour(): int 246 | { 247 | return $this->hour; 248 | } 249 | 250 | public function getMinute(): int 251 | { 252 | return $this->minute; 253 | } 254 | 255 | public function getSecond(): int 256 | { 257 | return $this->second; 258 | } 259 | 260 | public function getNano(): int 261 | { 262 | return $this->nano; 263 | } 264 | 265 | /** 266 | * Returns a copy of this LocalTime with the hour-of-day value altered. 267 | * 268 | * @param int $hour The new hour-of-day. 269 | * 270 | * @throws DateTimeException If the hour-of-day if not valid. 271 | */ 272 | public function withHour(int $hour): LocalTime 273 | { 274 | if ($hour === $this->hour) { 275 | return $this; 276 | } 277 | 278 | Field\HourOfDay::check($hour); 279 | 280 | return new LocalTime($hour, $this->minute, $this->second, $this->nano); 281 | } 282 | 283 | /** 284 | * Returns a copy of this LocalTime with the minute-of-hour value altered. 285 | * 286 | * @param int $minute The new minute-of-hour. 287 | * 288 | * @throws DateTimeException If the minute-of-hour if not valid. 289 | */ 290 | public function withMinute(int $minute): LocalTime 291 | { 292 | if ($minute === $this->minute) { 293 | return $this; 294 | } 295 | 296 | Field\MinuteOfHour::check($minute); 297 | 298 | return new LocalTime($this->hour, $minute, $this->second, $this->nano); 299 | } 300 | 301 | /** 302 | * Returns a copy of this LocalTime with the second-of-minute value altered. 303 | * 304 | * @param int $second The new second-of-minute. 305 | * 306 | * @throws DateTimeException If the second-of-minute if not valid. 307 | */ 308 | public function withSecond(int $second): LocalTime 309 | { 310 | if ($second === $this->second) { 311 | return $this; 312 | } 313 | 314 | Field\SecondOfMinute::check($second); 315 | 316 | return new LocalTime($this->hour, $this->minute, $second, $this->nano); 317 | } 318 | 319 | /** 320 | * Returns a copy of this LocalTime with the nano-of-second value altered. 321 | * 322 | * @param int $nano The new nano-of-second. 323 | * 324 | * @throws DateTimeException If the nano-of-second if not valid. 325 | */ 326 | public function withNano(int $nano): LocalTime 327 | { 328 | if ($nano === $this->nano) { 329 | return $this; 330 | } 331 | 332 | Field\NanoOfSecond::check($nano); 333 | 334 | return new LocalTime($this->hour, $this->minute, $this->second, $nano); 335 | } 336 | 337 | /** 338 | * Returns a copy of this LocalTime with the specific duration added. 339 | * 340 | * The calculation wraps around midnight. 341 | */ 342 | public function plusDuration(Duration $duration): LocalTime 343 | { 344 | return $this 345 | ->plusSeconds($duration->getSeconds()) 346 | ->plusNanos($duration->getNanos()); 347 | } 348 | 349 | /** 350 | * Returns a copy of this LocalTime with the specified period in hours added. 351 | * 352 | * This adds the specified number of hours to this time, returning a new time. 353 | * The calculation wraps around midnight. 354 | * 355 | * This instance is immutable and unaffected by this method call. 356 | * 357 | * @param int $hours The hours to add, may be negative. 358 | * 359 | * @return LocalTime A LocalTime based on this time with the hours added. 360 | */ 361 | public function plusHours(int $hours): LocalTime 362 | { 363 | if ($hours === 0) { 364 | return $this; 365 | } 366 | 367 | $hour = (($hours % self::HOURS_PER_DAY) + $this->hour + self::HOURS_PER_DAY) % self::HOURS_PER_DAY; 368 | 369 | return new LocalTime($hour, $this->minute, $this->second, $this->nano); 370 | } 371 | 372 | /** 373 | * Returns a copy of this LocalTime with the specified period in minutes added. 374 | * 375 | * This adds the specified number of minutes to this time, returning a new time. 376 | * The calculation wraps around midnight. 377 | * 378 | * This instance is immutable and unaffected by this method call. 379 | * 380 | * @param int $minutes The minutes to add, may be negative. 381 | * 382 | * @return LocalTime A LocalTime based on this time with the minutes added. 383 | */ 384 | public function plusMinutes(int $minutes): LocalTime 385 | { 386 | if ($minutes === 0) { 387 | return $this; 388 | } 389 | 390 | $mofd = $this->hour * self::MINUTES_PER_HOUR + $this->minute; 391 | $newMofd = (($minutes % self::MINUTES_PER_DAY) + $mofd + self::MINUTES_PER_DAY) % self::MINUTES_PER_DAY; 392 | 393 | if ($mofd === $newMofd) { 394 | return $this; 395 | } 396 | 397 | $hour = intdiv($newMofd, self::MINUTES_PER_HOUR); 398 | $minute = $newMofd % self::MINUTES_PER_HOUR; 399 | 400 | return new LocalTime($hour, $minute, $this->second, $this->nano); 401 | } 402 | 403 | /** 404 | * Returns a copy of this LocalTime with the specified period in seconds added. 405 | * 406 | * @param int $seconds The seconds to add, may be negative. 407 | * 408 | * @return LocalTime A LocalTime based on this time with the seconds added. 409 | */ 410 | public function plusSeconds(int $seconds): LocalTime 411 | { 412 | if ($seconds === 0) { 413 | return $this; 414 | } 415 | 416 | $sofd = $this->hour * self::SECONDS_PER_HOUR + $this->minute * self::SECONDS_PER_MINUTE + $this->second; 417 | $newSofd = (($seconds % self::SECONDS_PER_DAY) + $sofd + self::SECONDS_PER_DAY) % self::SECONDS_PER_DAY; 418 | 419 | if ($sofd === $newSofd) { 420 | return $this; 421 | } 422 | 423 | $hour = intdiv($newSofd, self::SECONDS_PER_HOUR); 424 | $minute = intdiv($newSofd, self::SECONDS_PER_MINUTE) % self::MINUTES_PER_HOUR; 425 | $second = $newSofd % self::SECONDS_PER_MINUTE; 426 | 427 | return new LocalTime($hour, $minute, $second, $this->nano); 428 | } 429 | 430 | /** 431 | * Returns a copy of this LocalTime with the specified period in nanoseconds added. 432 | * 433 | * @param int $nanos The seconds to add, may be negative. 434 | * 435 | * @return LocalTime A LocalTime based on this time with the nanoseconds added. 436 | * 437 | * @throws DateTimeException 438 | */ 439 | public function plusNanos(int $nanos): LocalTime 440 | { 441 | if ($nanos === 0) { 442 | return $this; 443 | } 444 | 445 | $divBase = Math::floorDiv($this->nano, LocalTime::NANOS_PER_SECOND); 446 | $modBase = Math::floorMod($this->nano, LocalTime::NANOS_PER_SECOND); 447 | 448 | $divPlus = Math::floorDiv($nanos, LocalTime::NANOS_PER_SECOND); 449 | $modPlus = Math::floorMod($nanos, LocalTime::NANOS_PER_SECOND); 450 | 451 | $diffSeconds = $divBase + $divPlus; 452 | $nano = $modBase + $modPlus; 453 | 454 | if ($nano >= LocalTime::NANOS_PER_SECOND) { 455 | $nano -= LocalTime::NANOS_PER_SECOND; 456 | $diffSeconds++; 457 | } 458 | 459 | return $this->withNano($nano)->plusSeconds($diffSeconds); 460 | } 461 | 462 | /** 463 | * Returns a copy of this LocalTime with the specific duration subtracted. 464 | * 465 | * The calculation wraps around midnight. 466 | */ 467 | public function minusDuration(Duration $duration): LocalTime 468 | { 469 | return $this->plusDuration($duration->negated()); 470 | } 471 | 472 | public function minusHours(int $hours): LocalTime 473 | { 474 | return $this->plusHours(-$hours); 475 | } 476 | 477 | public function minusMinutes(int $minutes): LocalTime 478 | { 479 | return $this->plusMinutes(-$minutes); 480 | } 481 | 482 | public function minusSeconds(int $seconds): LocalTime 483 | { 484 | return $this->plusSeconds(-$seconds); 485 | } 486 | 487 | public function minusNanos(int $nanos): LocalTime 488 | { 489 | return $this->plusNanos(-$nanos); 490 | } 491 | 492 | /** 493 | * Compares this LocalTime with another. 494 | * 495 | * @param LocalTime $that The time to compare to. 496 | * 497 | * @return int [-1,0,1] If this time is before, on, or after the given time. 498 | * 499 | * @psalm-return -1|0|1 500 | */ 501 | public function compareTo(LocalTime $that): int 502 | { 503 | $seconds = $this->toSecondOfDay() - $that->toSecondOfDay(); 504 | 505 | if ($seconds !== 0) { 506 | return $seconds > 0 ? 1 : -1; 507 | } 508 | 509 | $nanos = $this->nano - $that->nano; 510 | 511 | if ($nanos !== 0) { 512 | return $nanos > 0 ? 1 : -1; 513 | } 514 | 515 | return 0; 516 | } 517 | 518 | /** 519 | * Checks if this LocalTime is equal to the specified time. 520 | * 521 | * @param LocalTime $that The time to compare to. 522 | */ 523 | public function isEqualTo(LocalTime $that): bool 524 | { 525 | return $this->compareTo($that) === 0; 526 | } 527 | 528 | /** 529 | * Checks if this LocalTime is less than the specified time. 530 | * 531 | * @param LocalTime $that The time to compare to. 532 | */ 533 | public function isBefore(LocalTime $that): bool 534 | { 535 | return $this->compareTo($that) === -1; 536 | } 537 | 538 | /** 539 | * Checks if this LocalTime is less than the specified time. 540 | * 541 | * @param LocalTime $that The time to compare to. 542 | */ 543 | public function isBeforeOrEqualTo(LocalTime $that): bool 544 | { 545 | return $this->compareTo($that) <= 0; 546 | } 547 | 548 | /** 549 | * Checks if this LocalTime is greater than the specified time. 550 | * 551 | * @param LocalTime $that The time to compare to. 552 | */ 553 | public function isAfter(LocalTime $that): bool 554 | { 555 | return $this->compareTo($that) === 1; 556 | } 557 | 558 | /** 559 | * Checks if this LocalTime is greater than the specified time. 560 | * 561 | * @param LocalTime $that The time to compare to. 562 | */ 563 | public function isAfterOrEqualTo(LocalTime $that): bool 564 | { 565 | return $this->compareTo($that) >= 0; 566 | } 567 | 568 | /** 569 | * Combines this time with a date to create a LocalDateTime. 570 | */ 571 | public function atDate(LocalDate $date): LocalDateTime 572 | { 573 | return new LocalDateTime($date, $this); 574 | } 575 | 576 | /** 577 | * Returns the time as seconds of day, from 0 to 24 * 60 * 60 - 1. 578 | * 579 | * This does not include the nanoseconds. 580 | */ 581 | public function toSecondOfDay(): int 582 | { 583 | return $this->hour * self::SECONDS_PER_HOUR 584 | + $this->minute * self::SECONDS_PER_MINUTE 585 | + $this->second; 586 | } 587 | 588 | /** 589 | * Converts this LocalTime to a native DateTime object. 590 | * 591 | * The result is a DateTime with date 0000-01-01 in the UTC time-zone. 592 | * 593 | * Note that the native DateTime object supports a precision up to the microsecond, 594 | * so the nanoseconds are rounded down to the nearest microsecond. 595 | */ 596 | public function toNativeDateTime(): DateTime 597 | { 598 | return $this->atDate(LocalDate::of(0, Month::JANUARY, 1))->toNativeDateTime(); 599 | } 600 | 601 | /** 602 | * Converts this LocalTime to a native DateTimeImmutable object. 603 | * 604 | * The result is a DateTimeImmutable with date 0000-01-01 in the UTC time-zone. 605 | * 606 | * Note that the native DateTimeImmutable object supports a precision up to the microsecond, 607 | * so the nanoseconds are rounded down to the nearest microsecond. 608 | */ 609 | public function toNativeDateTimeImmutable(): DateTimeImmutable 610 | { 611 | return DateTimeImmutable::createFromMutable($this->toNativeDateTime()); 612 | } 613 | 614 | /** 615 | * Serializes as a string using {@see LocalTime::toISOString()}. 616 | * 617 | * @psalm-return non-empty-string 618 | */ 619 | public function jsonSerialize(): string 620 | { 621 | return $this->toISOString(); 622 | } 623 | 624 | /** 625 | * Returns the ISO 8601 representation of this time. 626 | * 627 | * The output will be one of the following formats: 628 | * 629 | * * `HH:mm` 630 | * * `HH:mm:ss` 631 | * * `HH:mm:ss.nnn` 632 | * 633 | * The format used will be the shortest that outputs the full value of 634 | * the time where the omitted parts are implied to be zero. 635 | * The nanoseconds value, if present, can be 0 to 9 digits. 636 | * 637 | * @psalm-return non-empty-string 638 | */ 639 | public function toISOString(): string 640 | { 641 | // This code is optimized for high performance 642 | return ($this->hour < 10 ? '0' . $this->hour : $this->hour) 643 | . ':' 644 | . ($this->minute < 10 ? '0' . $this->minute : $this->minute) 645 | . ($this->second !== 0 || $this->nano !== 0 ? ':' . ($this->second < 10 ? '0' . $this->second : $this->second) : '') 646 | . ($this->nano !== 0 ? '.' . rtrim(str_pad((string) $this->nano, 9, '0', STR_PAD_LEFT), '0') : ''); 647 | } 648 | 649 | /** 650 | * {@see LocalTime::toISOString()}. 651 | * 652 | * @psalm-return non-empty-string 653 | */ 654 | public function __toString(): string 655 | { 656 | return $this->toISOString(); 657 | } 658 | } 659 | -------------------------------------------------------------------------------- /src/LocalDateTime.php: -------------------------------------------------------------------------------- 1 | getDateTime(); 60 | } 61 | 62 | /** 63 | * @throws DateTimeException If the date-time is not valid. 64 | * @throws DateTimeParseException If required fields are missing from the result. 65 | */ 66 | public static function from(DateTimeParseResult $result): LocalDateTime 67 | { 68 | return new LocalDateTime( 69 | LocalDate::from($result), 70 | LocalTime::from($result), 71 | ); 72 | } 73 | 74 | /** 75 | * Obtains an instance of `LocalDateTime` from a text string. 76 | * 77 | * @param string $text The text to parse, such as `2007-12-03T10:15:30`. 78 | * @param DateTimeParser|null $parser The parser to use, defaults to the ISO 8601 parser. 79 | * 80 | * @throws DateTimeException If the date-time is not valid. 81 | * @throws DateTimeParseException If the text string does not follow the expected format. 82 | */ 83 | public static function parse(string $text, ?DateTimeParser $parser = null): LocalDateTime 84 | { 85 | if ($parser === null) { 86 | $parser = IsoParsers::localDateTime(); 87 | } 88 | 89 | return LocalDateTime::from($parser->parse($text)); 90 | } 91 | 92 | /** 93 | * Creates a LocalDateTime from a native DateTime or DateTimeImmutable object. 94 | */ 95 | public static function fromNativeDateTime(DateTimeInterface $dateTime): LocalDateTime 96 | { 97 | return new LocalDateTime( 98 | LocalDate::fromNativeDateTime($dateTime), 99 | LocalTime::fromNativeDateTime($dateTime), 100 | ); 101 | } 102 | 103 | /** 104 | * Returns the smallest possible value for LocalDateTime. 105 | */ 106 | public static function min(): LocalDateTime 107 | { 108 | /** @var LocalDateTime|null $min */ 109 | static $min = null; 110 | 111 | return $min ??= new LocalDateTime(LocalDate::min(), LocalTime::min()); 112 | } 113 | 114 | /** 115 | * Returns the highest possible value for LocalDateTime. 116 | */ 117 | public static function max(): LocalDateTime 118 | { 119 | /** @var LocalDateTime|null $max */ 120 | static $max = null; 121 | 122 | return $max ??= new LocalDateTime(LocalDate::max(), LocalTime::max()); 123 | } 124 | 125 | /** 126 | * Returns the smallest LocalDateTime among the given values. 127 | * 128 | * @param LocalDateTime ...$times The LocalDateTime objects to compare. 129 | * 130 | * @return LocalDateTime The earliest LocalDateTime object. 131 | * 132 | * @throws DateTimeException If the array is empty. 133 | */ 134 | public static function minOf(LocalDateTime ...$times): LocalDateTime 135 | { 136 | if ($times === []) { 137 | throw new DateTimeException(__METHOD__ . ' does not accept less than 1 parameter.'); 138 | } 139 | 140 | $min = null; 141 | 142 | foreach ($times as $time) { 143 | if ($min === null || $time->isBefore($min)) { 144 | $min = $time; 145 | } 146 | } 147 | 148 | return $min; 149 | } 150 | 151 | /** 152 | * Returns the highest LocalDateTime among the given values. 153 | * 154 | * @param LocalDateTime ...$times The LocalDateTime objects to compare. 155 | * 156 | * @return LocalDateTime The latest LocalDateTime object. 157 | * 158 | * @throws DateTimeException If the array is empty. 159 | */ 160 | public static function maxOf(LocalDateTime ...$times): LocalDateTime 161 | { 162 | if ($times === []) { 163 | throw new DateTimeException(__METHOD__ . ' does not accept less than 1 parameter.'); 164 | } 165 | 166 | $max = null; 167 | 168 | foreach ($times as $time) { 169 | if ($max === null || $time->isAfter($max)) { 170 | $max = $time; 171 | } 172 | } 173 | 174 | return $max; 175 | } 176 | 177 | public function getDate(): LocalDate 178 | { 179 | return $this->date; 180 | } 181 | 182 | public function getTime(): LocalTime 183 | { 184 | return $this->time; 185 | } 186 | 187 | public function getYear(): int 188 | { 189 | return $this->date->getYear(); 190 | } 191 | 192 | /** 193 | * Returns the month-of-year as a Month enum. 194 | */ 195 | public function getMonth(): Month 196 | { 197 | return $this->date->getMonth(); 198 | } 199 | 200 | /** 201 | * Returns the month-of-year value from 1 to 12. 202 | * 203 | * @return int<1, 12> 204 | */ 205 | public function getMonthValue(): int 206 | { 207 | return $this->date->getMonthValue(); 208 | } 209 | 210 | /** 211 | * @return int<1, 31> 212 | */ 213 | public function getDayOfMonth(): int 214 | { 215 | return $this->date->getDayOfMonth(); 216 | } 217 | 218 | public function getDayOfWeek(): DayOfWeek 219 | { 220 | return $this->date->getDayOfWeek(); 221 | } 222 | 223 | /** 224 | * @return int<1, 366> 225 | */ 226 | public function getDayOfYear(): int 227 | { 228 | return $this->date->getDayOfYear(); 229 | } 230 | 231 | public function getHour(): int 232 | { 233 | return $this->time->getHour(); 234 | } 235 | 236 | public function getMinute(): int 237 | { 238 | return $this->time->getMinute(); 239 | } 240 | 241 | public function getSecond(): int 242 | { 243 | return $this->time->getSecond(); 244 | } 245 | 246 | public function getNano(): int 247 | { 248 | return $this->time->getNano(); 249 | } 250 | 251 | /** 252 | * Returns a copy of this LocalDateTime with the date altered. 253 | */ 254 | public function withDate(LocalDate $date): LocalDateTime 255 | { 256 | if ($date->isEqualTo($this->date)) { 257 | return $this; 258 | } 259 | 260 | return new LocalDateTime($date, $this->time); 261 | } 262 | 263 | /** 264 | * Returns a copy of this LocalDateTime with the time altered. 265 | */ 266 | public function withTime(LocalTime $time): LocalDateTime 267 | { 268 | if ($time->isEqualTo($this->time)) { 269 | return $this; 270 | } 271 | 272 | return new LocalDateTime($this->date, $time); 273 | } 274 | 275 | /** 276 | * Returns a copy of this LocalDateTime with the year altered. 277 | * 278 | * If the day-of-month is invalid for the year, it will be changed to the last valid day of the month. 279 | * 280 | * @throws DateTimeException If the year is outside the valid range. 281 | */ 282 | public function withYear(int $year): LocalDateTime 283 | { 284 | $date = $this->date->withYear($year); 285 | 286 | if ($date === $this->date) { 287 | return $this; 288 | } 289 | 290 | return new LocalDateTime($date, $this->time); 291 | } 292 | 293 | /** 294 | * Returns a copy of this LocalDateTime with the month-of-year altered. 295 | * 296 | * If the day-of-month is invalid for the month and year, it will be changed to the last valid day of the month. 297 | * 298 | * @throws DateTimeException If the month is invalid. 299 | */ 300 | public function withMonth(int|Month $month): LocalDateTime 301 | { 302 | $date = $this->date->withMonth($month); 303 | 304 | if ($date === $this->date) { 305 | return $this; 306 | } 307 | 308 | return new LocalDateTime($date, $this->time); 309 | } 310 | 311 | /** 312 | * Returns a copy of this LocalDateTime with the day-of-month altered. 313 | * 314 | * If the resulting date is invalid, an exception is thrown. 315 | * 316 | * @throws DateTimeException If the day is invalid for the current year and month. 317 | */ 318 | public function withDay(int $day): LocalDateTime 319 | { 320 | $date = $this->date->withDay($day); 321 | 322 | if ($date === $this->date) { 323 | return $this; 324 | } 325 | 326 | return new LocalDateTime($date, $this->time); 327 | } 328 | 329 | /** 330 | * Returns a copy of this LocalDateTime with the hour-of-day altered. 331 | * 332 | * @throws DateTimeException If the hour is invalid. 333 | */ 334 | public function withHour(int $hour): LocalDateTime 335 | { 336 | $time = $this->time->withHour($hour); 337 | 338 | if ($time === $this->time) { 339 | return $this; 340 | } 341 | 342 | return new LocalDateTime($this->date, $time); 343 | } 344 | 345 | /** 346 | * Returns a copy of this LocalDateTime with the minute-of-hour altered. 347 | * 348 | * @throws DateTimeException If the minute-of-hour if not valid. 349 | */ 350 | public function withMinute(int $minute): LocalDateTime 351 | { 352 | $time = $this->time->withMinute($minute); 353 | 354 | if ($time === $this->time) { 355 | return $this; 356 | } 357 | 358 | return new LocalDateTime($this->date, $time); 359 | } 360 | 361 | /** 362 | * Returns a copy of this LocalDateTime with the second-of-minute altered. 363 | * 364 | * @throws DateTimeException If the second-of-minute if not valid. 365 | */ 366 | public function withSecond(int $second): LocalDateTime 367 | { 368 | $time = $this->time->withSecond($second); 369 | 370 | if ($time === $this->time) { 371 | return $this; 372 | } 373 | 374 | return new LocalDateTime($this->date, $time); 375 | } 376 | 377 | /** 378 | * Returns a copy of this LocalDateTime with the nano-of-second altered. 379 | * 380 | * @throws DateTimeException If the nano-of-second if not valid. 381 | */ 382 | public function withNano(int $nano): LocalDateTime 383 | { 384 | $time = $this->time->withNano($nano); 385 | 386 | if ($time === $this->time) { 387 | return $this; 388 | } 389 | 390 | return new LocalDateTime($this->date, $time); 391 | } 392 | 393 | /** 394 | * Returns a zoned date-time formed from this date-time and the specified time-zone. 395 | * 396 | * @param TimeZone $zone The zime-zone to use. 397 | * 398 | * @return ZonedDateTime The zoned date-time formed from this date-time. 399 | */ 400 | public function atTimeZone(TimeZone $zone): ZonedDateTime 401 | { 402 | return ZonedDateTime::of($this, $zone); 403 | } 404 | 405 | /** 406 | * Returns a copy of this LocalDateTime with the specified Period added. 407 | */ 408 | public function plusPeriod(Period $period): LocalDateTime 409 | { 410 | $date = $this->date->plusPeriod($period); 411 | 412 | if ($date === $this->date) { 413 | return $this; 414 | } 415 | 416 | return new LocalDateTime($date, $this->time); 417 | } 418 | 419 | /** 420 | * Returns a copy of this LocalDateTime with the specific Duration added. 421 | */ 422 | public function plusDuration(Duration $duration): LocalDateTime 423 | { 424 | if ($duration->isZero()) { 425 | return $this; 426 | } 427 | 428 | $days = Math::floorDiv($this->time->toSecondOfDay() + $duration->getSeconds(), LocalTime::SECONDS_PER_DAY); 429 | 430 | return new LocalDateTime($this->date->plusDays($days), $this->time->plusDuration($duration)); 431 | } 432 | 433 | /** 434 | * Returns a copy of this LocalDateTime with the specified period in years added. 435 | * 436 | * @throws DateTimeException If the resulting year is out of range. 437 | */ 438 | public function plusYears(int $years): LocalDateTime 439 | { 440 | if ($years === 0) { 441 | return $this; 442 | } 443 | 444 | return new LocalDateTime($this->date->plusYears($years), $this->time); 445 | } 446 | 447 | /** 448 | * Returns a copy of this LocalDateTime with the specified period in months added. 449 | */ 450 | public function plusMonths(int $months): LocalDateTime 451 | { 452 | if ($months === 0) { 453 | return $this; 454 | } 455 | 456 | return new LocalDateTime($this->date->plusMonths($months), $this->time); 457 | } 458 | 459 | /** 460 | * Returns a copy of this LocalDateTime with the specified period in weeks added. 461 | */ 462 | public function plusWeeks(int $weeks): LocalDateTime 463 | { 464 | if ($weeks === 0) { 465 | return $this; 466 | } 467 | 468 | return new LocalDateTime($this->date->plusWeeks($weeks), $this->time); 469 | } 470 | 471 | /** 472 | * Returns a copy of this LocalDateTime with the specified period in days added. 473 | */ 474 | public function plusDays(int $days): LocalDateTime 475 | { 476 | if ($days === 0) { 477 | return $this; 478 | } 479 | 480 | return new LocalDateTime($this->date->plusDays($days), $this->time); 481 | } 482 | 483 | /** 484 | * Returns a copy of this LocalDateTime with the specified period in hours added. 485 | */ 486 | public function plusHours(int $hours): LocalDateTime 487 | { 488 | if ($hours === 0) { 489 | return $this; 490 | } 491 | 492 | return $this->plusWithOverflow($hours, 0, 0, 0, 1); 493 | } 494 | 495 | /** 496 | * Returns a copy of this LocalDateTime with the specified period in minutes added. 497 | */ 498 | public function plusMinutes(int $minutes): LocalDateTime 499 | { 500 | if ($minutes === 0) { 501 | return $this; 502 | } 503 | 504 | return $this->plusWithOverflow(0, $minutes, 0, 0, 1); 505 | } 506 | 507 | /** 508 | * Returns a copy of this LocalDateTime with the specified period in seconds added. 509 | */ 510 | public function plusSeconds(int $seconds): LocalDateTime 511 | { 512 | if ($seconds === 0) { 513 | return $this; 514 | } 515 | 516 | return $this->plusWithOverflow(0, 0, $seconds, 0, 1); 517 | } 518 | 519 | /** 520 | * Returns a copy of this LocalDateTime with the specified period in nanoseconds added. 521 | */ 522 | public function plusNanos(int $nanos): LocalDateTime 523 | { 524 | if ($nanos === 0) { 525 | return $this; 526 | } 527 | 528 | return $this->plusWithOverflow(0, 0, 0, $nanos, 1); 529 | } 530 | 531 | /** 532 | * Returns a copy of this LocalDateTime with the specified Period subtracted. 533 | */ 534 | public function minusPeriod(Period $period): LocalDateTime 535 | { 536 | return $this->plusPeriod($period->negated()); 537 | } 538 | 539 | /** 540 | * Returns a copy of this LocalDateTime with the specific Duration subtracted. 541 | */ 542 | public function minusDuration(Duration $duration): LocalDateTime 543 | { 544 | return $this->plusDuration($duration->negated()); 545 | } 546 | 547 | /** 548 | * Returns a copy of this LocalDateTime with the specified period in years subtracted. 549 | */ 550 | public function minusYears(int $years): LocalDateTime 551 | { 552 | if ($years === 0) { 553 | return $this; 554 | } 555 | 556 | return new LocalDateTime($this->date->minusYears($years), $this->time); 557 | } 558 | 559 | /** 560 | * Returns a copy of this LocalDateTime with the specified period in months subtracted. 561 | */ 562 | public function minusMonths(int $months): LocalDateTime 563 | { 564 | if ($months === 0) { 565 | return $this; 566 | } 567 | 568 | return new LocalDateTime($this->date->minusMonths($months), $this->time); 569 | } 570 | 571 | /** 572 | * Returns a copy of this LocalDateTime with the specified period in weeks subtracted. 573 | */ 574 | public function minusWeeks(int $weeks): LocalDateTime 575 | { 576 | if ($weeks === 0) { 577 | return $this; 578 | } 579 | 580 | return new LocalDateTime($this->date->minusWeeks($weeks), $this->time); 581 | } 582 | 583 | /** 584 | * Returns a copy of this LocalDateTime with the specified period in days subtracted. 585 | */ 586 | public function minusDays(int $days): LocalDateTime 587 | { 588 | if ($days === 0) { 589 | return $this; 590 | } 591 | 592 | return new LocalDateTime($this->date->minusDays($days), $this->time); 593 | } 594 | 595 | /** 596 | * Returns a copy of this LocalDateTime with the specified period in hours subtracted. 597 | */ 598 | public function minusHours(int $hours): LocalDateTime 599 | { 600 | if ($hours === 0) { 601 | return $this; 602 | } 603 | 604 | return $this->plusWithOverflow($hours, 0, 0, 0, -1); 605 | } 606 | 607 | /** 608 | * Returns a copy of this LocalDateTime with the specified period in minutes subtracted. 609 | */ 610 | public function minusMinutes(int $minutes): LocalDateTime 611 | { 612 | if ($minutes === 0) { 613 | return $this; 614 | } 615 | 616 | return $this->plusWithOverflow(0, $minutes, 0, 0, -1); 617 | } 618 | 619 | /** 620 | * Returns a copy of this LocalDateTime with the specified period in seconds subtracted. 621 | */ 622 | public function minusSeconds(int $seconds): LocalDateTime 623 | { 624 | if ($seconds === 0) { 625 | return $this; 626 | } 627 | 628 | return $this->plusWithOverflow(0, 0, $seconds, 0, -1); 629 | } 630 | 631 | /** 632 | * Returns a copy of this LocalDateTime with the specified period in nanoseconds subtracted. 633 | */ 634 | public function minusNanos(int $nanos): LocalDateTime 635 | { 636 | if ($nanos === 0) { 637 | return $this; 638 | } 639 | 640 | return $this->plusWithOverflow(0, 0, 0, $nanos, -1); 641 | } 642 | 643 | /** 644 | * Compares this date-time to another date-time. 645 | * 646 | * @param LocalDateTime $that The date-time to compare to. 647 | * 648 | * @return int [-1,0,1] If this date-time is before, on, or after the given date-time. 649 | * 650 | * @psalm-return -1|0|1 651 | */ 652 | public function compareTo(LocalDateTime $that): int 653 | { 654 | $cmp = $this->date->compareTo($that->date); 655 | 656 | if ($cmp !== 0) { 657 | return $cmp; 658 | } 659 | 660 | return $this->time->compareTo($that->time); 661 | } 662 | 663 | public function isEqualTo(LocalDateTime $that): bool 664 | { 665 | return $this->compareTo($that) === 0; 666 | } 667 | 668 | public function isBefore(LocalDateTime $that): bool 669 | { 670 | return $this->compareTo($that) === -1; 671 | } 672 | 673 | public function isBeforeOrEqualTo(LocalDateTime $that): bool 674 | { 675 | return $this->compareTo($that) <= 0; 676 | } 677 | 678 | public function isAfter(LocalDateTime $that): bool 679 | { 680 | return $this->compareTo($that) === 1; 681 | } 682 | 683 | public function isAfterOrEqualTo(LocalDateTime $that): bool 684 | { 685 | return $this->compareTo($that) >= 0; 686 | } 687 | 688 | /** 689 | * Returns whether this LocalDateTime is in the future, in the given time-zone, according to the given clock. 690 | * 691 | * If no clock is provided, the system clock is used. 692 | */ 693 | public function isFuture(TimeZone $timeZone, ?Clock $clock = null): bool 694 | { 695 | return $this->isAfter(LocalDateTime::now($timeZone, $clock)); 696 | } 697 | 698 | /** 699 | * Returns whether this LocalDateTime is in the past, in the given time-zone, according to the given clock. 700 | * 701 | * If no clock is provided, the system clock is used. 702 | */ 703 | public function isPast(TimeZone $timeZone, ?Clock $clock = null): bool 704 | { 705 | return $this->isBefore(LocalDateTime::now($timeZone, $clock)); 706 | } 707 | 708 | /** 709 | * Converts this LocalDateTime to a native DateTime object. 710 | * 711 | * The result is a DateTime in the UTC time-zone. 712 | * 713 | * Note that the native DateTime object supports a precision up to the microsecond, 714 | * so the nanoseconds are rounded down to the nearest microsecond. 715 | */ 716 | public function toNativeDateTime(): DateTime 717 | { 718 | return $this->atTimeZone(TimeZone::utc())->toNativeDateTime(); 719 | } 720 | 721 | /** 722 | * Converts this LocalDateTime to a native DateTimeImmutable object. 723 | * 724 | * The result is a DateTimeImmutable in the UTC time-zone. 725 | * 726 | * Note that the native DateTimeImmutable object supports a precision up to the microsecond, 727 | * so the nanoseconds are rounded down to the nearest microsecond. 728 | */ 729 | public function toNativeDateTimeImmutable(): DateTimeImmutable 730 | { 731 | return DateTimeImmutable::createFromMutable($this->toNativeDateTime()); 732 | } 733 | 734 | /** 735 | * Serializes as a string using {@see LocalDateTime::toISOString()}. 736 | * 737 | * @psalm-return non-empty-string 738 | */ 739 | public function jsonSerialize(): string 740 | { 741 | return $this->toISOString(); 742 | } 743 | 744 | /** 745 | * Returns the ISO 8601 representation of this date time. 746 | * 747 | * @psalm-return non-empty-string 748 | */ 749 | public function toISOString(): string 750 | { 751 | return $this->date . 'T' . $this->time; 752 | } 753 | 754 | /** 755 | * {@see LocalDateTime::toISOString()}. 756 | * 757 | * @psalm-return non-empty-string 758 | */ 759 | public function __toString(): string 760 | { 761 | return $this->toISOString(); 762 | } 763 | 764 | /** 765 | * Returns a copy of this `LocalDateTime` with the specified period added. 766 | * 767 | * @param int $hours The hours to add. May be negative. 768 | * @param int $minutes The minutes to add. May be negative. 769 | * @param int $seconds The seconds to add. May be negative. 770 | * @param int $nanos The nanos to add. May be negative. 771 | * @param int $sign The sign, validated as `1` to add or `-1` to subtract. 772 | * 773 | * @return LocalDateTime The combined result. 774 | */ 775 | private function plusWithOverflow(int $hours, int $minutes, int $seconds, int $nanos, int $sign): LocalDateTime 776 | { 777 | $totDays = 778 | intdiv($hours, LocalTime::HOURS_PER_DAY) + 779 | intdiv($minutes, LocalTime::MINUTES_PER_DAY) + 780 | intdiv($seconds, LocalTime::SECONDS_PER_DAY); 781 | $totDays *= $sign; 782 | 783 | $totSeconds = 784 | ($seconds % LocalTime::SECONDS_PER_DAY) + 785 | ($minutes % LocalTime::MINUTES_PER_DAY) * LocalTime::SECONDS_PER_MINUTE + 786 | ($hours % LocalTime::HOURS_PER_DAY) * LocalTime::SECONDS_PER_HOUR; 787 | 788 | $curSoD = $this->time->toSecondOfDay(); 789 | $totSeconds = $totSeconds * $sign + $curSoD; 790 | 791 | $totNanos = $nanos * $sign + $this->time->getNano(); 792 | $totSeconds += Math::floorDiv($totNanos, LocalTime::NANOS_PER_SECOND); 793 | $newNano = Math::floorMod($totNanos, LocalTime::NANOS_PER_SECOND); 794 | 795 | $totDays += Math::floorDiv($totSeconds, LocalTime::SECONDS_PER_DAY); 796 | $newSoD = Math::floorMod($totSeconds, LocalTime::SECONDS_PER_DAY); 797 | 798 | $newDate = $this->date->plusDays($totDays); 799 | $newTime = ($newSoD === $curSoD ? $this->time : LocalTime::ofSecondOfDay($newSoD, $newNano)); 800 | 801 | return new LocalDateTime($newDate, $newTime); 802 | } 803 | } 804 | --------------------------------------------------------------------------------