src/Pure/General/date.scala
author wenzelm
Wed, 12 Mar 2025 11:39:00 +0100
changeset 82265 4b875a4c83b0
parent 81964 8efec8da78f9
permissions -rw-r--r--
update for release;

/*  Title:      Pure/General/date.scala
    Author:     Makarius
    Author:     Fabian Huch, TU München

Date and time, with timezone.
*/

package isabelle


import java.util.Locale
import java.time.{Instant, ZonedDateTime, LocalTime, ZoneId}
import java.time.format.{DateTimeFormatter, DateTimeParseException}
import java.time.temporal.{ChronoUnit, TemporalAccessor}

import scala.annotation.tailrec


object Date {
  /* format */

  object Format {
    def make(fmts: List[DateTimeFormatter], tune: String => String = s => s): Format = {
      require(fmts.nonEmpty, "no date formats")

      new Format {
        def apply(date: Date): String = fmts.head.format(date.rep)
        def parse(str: String): Date =
          new Date(ZonedDateTime.from(Formatter.try_variants(fmts, tune(str))))
      }
    }

    def apply(pats: String*): Format =
      make(pats.toList.map(Date.Formatter.pattern))

    val default: Format = Format("dd-MMM-uuuu HH:mm:ss xx")
    val date: Format = Format("dd-MMM-uuuu")
    val time: Format = Format("HH:mm:ss")
    val alt_date: Format = Format("uuuuMMdd")
  }

  abstract class Format private {
    def apply(date: Date): String
    def parse(str: String): Date

    def unapply(str: String): Option[Date] =
      try { Some(parse(str)) } catch { case _: DateTimeParseException => None }
  }

  object Formatter {
    def pattern(pat: String): DateTimeFormatter = DateTimeFormatter.ofPattern(pat)

    def variants(pats: List[String], locs: List[Locale] = Nil): List[DateTimeFormatter] =
      pats.flatMap { pat =>
        val fmt = pattern(pat)
        if (locs.isEmpty) List(fmt) else locs.map(fmt.withLocale)
      }

    @tailrec def try_variants(
      fmts: List[DateTimeFormatter],
      str: String,
      last_exn: Option[DateTimeParseException] = None
    ): TemporalAccessor = {
      fmts match {
        case Nil =>
          throw last_exn.getOrElse(new DateTimeParseException("Failed to parse date", str, 0))
        case fmt :: rest =>
          try { ZonedDateTime.from(fmt.parse(str)) }
          catch { case exn: DateTimeParseException => try_variants(rest, str, Some(exn)) }
      }
    }
  }


  /* ordering */

  object Ordering extends scala.math.Ordering[Date] {
    def compare(date1: Date, date2: Date): Int =
      date1.instant.compareTo(date2.instant)
  }

  object Rev_Ordering extends scala.math.Ordering[Date] {
    def compare(date1: Date, date2: Date): Int =
      date2.instant.compareTo(date1.instant)
  }


  /* date operations */

  def timezone_utc: ZoneId = ZoneId.of("UTC")
  def timezone_berlin: ZoneId = ZoneId.of("Europe/Berlin")

  def timezone(): ZoneId = ZoneId.systemDefault

  def now(timezone: ZoneId = Date.timezone()): Date =
    new Date(ZonedDateTime.now(timezone))

  def instant(t: Instant, timezone: ZoneId = Date.timezone()): Date =
    new Date(ZonedDateTime.ofInstant(t, timezone))

  def apply(t: Time, timezone: ZoneId = Date.timezone()): Date =
    instant(t.instant, timezone)


  /* varying-length calendar cycles */

  enum Day { case mon, tue, wed, thu, fri, sat, sun }
  enum Month { case jan, feb, mar, apr, may, jun, jul, aug, sep, okt, nov, dec }

  sealed trait Cycle {
    def zero(date: Date): Date
    def next(date: Date): Date
  }

  case class Daily(at: Time = Time.zero) extends Cycle {
    require(at >= Time.zero && at < Time.hms(24, 0, 0))

    def zero(date: Date): Date = date.midnight
    def next(date: Date): Date = {
      val start = zero(date) + at
      if (date.time < start.time) start else start.shift(1)
    }

    override def toString: String = "Daily(" + Format.time(Date(at, timezone_utc)) + ")"
  }

  case class Weekly(on: Day = Day.mon, step: Daily = Daily()) extends Cycle {
    def zero(date: Date): Date = date.shift(1 - date.rep.getDayOfWeek.getValue).midnight
    def next(date: Date): Date = {
      val next = step.next(zero(date).shift(on.ordinal) - Time.ms(1))
      if (date.time < next.time) next else Date(next.rep.plus(1, ChronoUnit.WEEKS))
    }
  }

  case class Monthly(nth: Int = 1, step: Daily = Daily()) extends Cycle {
    require(nth > 0 && nth <= 31)

    def zero(date: Date): Date = date.shift(1 - date.rep.getDayOfMonth).midnight
    def next(date: Date): Date = {
      @tailrec def find_next(zero: Date): Date = {
        val next = step.next(zero.shift(nth - 1) - Time.ms(1))
        if (next.rep.getDayOfMonth == nth && date.time < next.time) next
        else find_next(Date(zero.rep.plus(1, ChronoUnit.MONTHS)))
      }
      find_next(zero(date))
    }
  }

  case class Yearly(in: Month = Month.jan, step: Monthly = Monthly()) extends Cycle {
    def zero(date: Date): Date = date.shift(1 - date.rep.getDayOfYear).midnight
    def next(date: Date): Date = {
      @tailrec def find_next(zero: Date): Date = {
        val next = step.next(Date(zero.rep.plus(in.ordinal, ChronoUnit.MONTHS)) - Time.ms(1))
        if (next.rep.getMonthValue - 1 == in.ordinal && date.time < next.time) next
        else find_next(Date(zero.rep.plus(1, ChronoUnit.YEARS)))
      }
      find_next(zero(date))
    }
  }
}

sealed case class Date(rep: ZonedDateTime) {
  def shift(days: Int): Date = Date(rep.plus(days, ChronoUnit.DAYS))
  def midnight: Date =
    new Date(ZonedDateTime.of(rep.toLocalDate, LocalTime.MIDNIGHT, rep.getZone))

  def to(other: ZoneId): Date = new Date(rep.withZoneSameInstant(other))

  def unix_epoch: Long = rep.toEpochSecond
  def unix_epoch_day: Long = rep.toLocalDate.toEpochDay
  def instant: Instant = Instant.from(rep)
  def time: Time = Time.instant(instant)
  def timezone: ZoneId = rep.getZone

  def + (t: Time): Date = Date(time + t, timezone = timezone)
  def - (t: Time): Date = Date(time - t, timezone = timezone)
  def - (d: Date): Time = time - d.time

  def format(fmt: Date.Format = Date.Format.default): String = fmt(this)
  override def toString: String = format()
}