diff --git a/Cargo.lock b/Cargo.lock index c09a251..b976fba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1561,8 +1561,11 @@ dependencies = [ name = "lune-std-datetime" version = "0.1.0" dependencies = [ + "chrono", + "chrono_lc", "lune-utils", "mlua", + "thiserror", ] [[package]] diff --git a/crates/lune-std-datetime/Cargo.toml b/crates/lune-std-datetime/Cargo.toml index 07ed272..34bef78 100644 --- a/crates/lune-std-datetime/Cargo.toml +++ b/crates/lune-std-datetime/Cargo.toml @@ -13,4 +13,8 @@ workspace = true [dependencies] mlua = { version = "0.9.7", features = ["luau"] } +thiserror = "1.0" +chrono = "=0.4.34" # NOTE: 0.4.35 does not compile with chrono_lc +chrono_lc = "0.1" + lune-utils = { version = "0.1.0", path = "../lune-utils" } diff --git a/crates/lune-std-datetime/src/date_time.rs b/crates/lune-std-datetime/src/date_time.rs new file mode 100644 index 0000000..a5ac50d --- /dev/null +++ b/crates/lune-std-datetime/src/date_time.rs @@ -0,0 +1,228 @@ +use std::cmp::Ordering; + +use mlua::prelude::*; + +use chrono::prelude::*; +use chrono::DateTime as ChronoDateTime; +use chrono_lc::LocaleDate; + +use crate::result::{DateTimeError, DateTimeResult}; +use crate::values::DateTimeValues; + +const DEFAULT_FORMAT: &str = "%Y-%m-%d %H:%M:%S"; +const DEFAULT_LOCALE: &str = "en"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct DateTime { + // NOTE: We store this as the UTC time zone since it is the most commonly + // used and getting the generics right for TimeZone is somewhat tricky, + // but none of the method implementations below should rely on this tz + inner: ChronoDateTime, +} + +impl DateTime { + /** + Creates a new `DateTime` struct representing the current moment in time. + + See [`chrono::DateTime::now`] for additional details. + */ + pub fn now() -> Self { + Self { inner: Utc::now() } + } + + /** + Creates a new `DateTime` struct from the given `unix_timestamp`, + which is a float of seconds passed since the UNIX epoch. + + This is somewhat unconventional, but fits our Luau interface and dynamic types quite well. + To use this method the same way you would use a more traditional `from_unix_timestamp` + that takes a `u64` of seconds or similar type, casting the value is sufficient: + + ```rust ignore + DateTime::from_unix_timestamp_float(123456789u64 as f64) + ``` + + See [`chrono::DateTime::from_timestamp`] for additional details. + */ + pub fn from_unix_timestamp_float(unix_timestamp: f64) -> DateTimeResult { + let whole = unix_timestamp.trunc() as i64; + let fract = unix_timestamp.fract(); + let nanos = (fract * 1_000_000_000f64) + .round() + .clamp(u32::MIN as f64, u32::MAX as f64) as u32; + let inner = ChronoDateTime::::from_timestamp(whole, nanos) + .ok_or(DateTimeError::OutOfRangeUnspecified)?; + Ok(Self { inner }) + } + + /** + Transforms individual date & time values into a new + `DateTime` struct, using the universal (UTC) time zone. + + See [`chrono::NaiveDate::from_ymd_opt`] and [`chrono::NaiveTime::from_hms_milli_opt`] + for additional details and cases where this constructor may return an error. + */ + pub fn from_universal_time(values: &DateTimeValues) -> DateTimeResult { + let date = NaiveDate::from_ymd_opt(values.year, values.month, values.day) + .ok_or(DateTimeError::InvalidDate)?; + + let time = NaiveTime::from_hms_milli_opt( + values.hour, + values.minute, + values.second, + values.millisecond, + ) + .ok_or(DateTimeError::InvalidTime)?; + + let inner = Utc.from_utc_datetime(&NaiveDateTime::new(date, time)); + + Ok(Self { inner }) + } + + /** + Transforms individual date & time values into a new + `DateTime` struct, using the current local time zone. + + See [`chrono::NaiveDate::from_ymd_opt`] and [`chrono::NaiveTime::from_hms_milli_opt`] + for additional details and cases where this constructor may return an error. + */ + pub fn from_local_time(values: &DateTimeValues) -> DateTimeResult { + let date = NaiveDate::from_ymd_opt(values.year, values.month, values.day) + .ok_or(DateTimeError::InvalidDate)?; + + let time = NaiveTime::from_hms_milli_opt( + values.hour, + values.minute, + values.second, + values.millisecond, + ) + .ok_or(DateTimeError::InvalidTime)?; + + let inner = Local + .from_local_datetime(&NaiveDateTime::new(date, time)) + .single() + .ok_or(DateTimeError::Ambiguous)? + .with_timezone(&Utc); + + Ok(Self { inner }) + } + + /** + Formats the `DateTime` using the universal (UTC) time + zone, the given format string, and the given locale. + + `format` and `locale` default to `"%Y-%m-%d %H:%M:%S"` and `"en"` respectively. + + See [`chrono_lc::DateTime::formatl`] for additional details. + */ + pub fn format_string_local(&self, format: Option<&str>, locale: Option<&str>) -> String { + self.inner + .with_timezone(&Local) + .formatl( + format.unwrap_or(DEFAULT_FORMAT), + locale.unwrap_or(DEFAULT_LOCALE), + ) + .to_string() + } + + /** + Formats the `DateTime` using the universal (UTC) time + zone, the given format string, and the given locale. + + `format` and `locale` default to `"%Y-%m-%d %H:%M:%S"` and `"en"` respectively. + + See [`chrono_lc::DateTime::formatl`] for additional details. + */ + pub fn format_string_universal(&self, format: Option<&str>, locale: Option<&str>) -> String { + self.inner + .with_timezone(&Utc) + .formatl( + format.unwrap_or(DEFAULT_FORMAT), + locale.unwrap_or(DEFAULT_LOCALE), + ) + .to_string() + } + + /** + Parses a time string in the ISO 8601 format, such as + `1996-12-19T16:39:57-08:00`, into a new `DateTime` struct. + + See [`chrono::DateTime::parse_from_rfc3339`] for additional details. + */ + pub fn from_iso_date(iso_date: impl AsRef) -> DateTimeResult { + let inner = ChronoDateTime::parse_from_rfc3339(iso_date.as_ref())?.with_timezone(&Utc); + Ok(Self { inner }) + } + + /** + Extracts individual date & time values from this + `DateTime`, using the current local time zone. + */ + pub fn to_local_time(self) -> DateTimeValues { + DateTimeValues::from(self.inner.with_timezone(&Local)) + } + + /** + Extracts individual date & time values from this + `DateTime`, using the universal (UTC) time zone. + */ + pub fn to_universal_time(self) -> DateTimeValues { + DateTimeValues::from(self.inner.with_timezone(&Utc)) + } + + /** + Formats a time string in the ISO 8601 format, such as `1996-12-19T16:39:57-08:00`. + + See [`chrono::DateTime::to_rfc3339`] for additional details. + */ + pub fn to_iso_date(self) -> String { + self.inner.to_rfc3339() + } +} + +impl LuaUserData for DateTime { + fn add_fields<'lua, F: LuaUserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("unixTimestamp", |_, this| Ok(this.inner.timestamp())); + fields.add_field_method_get("unixTimestampMillis", |_, this| { + Ok(this.inner.timestamp_millis()) + }); + } + + fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) { + // Metamethods to compare DateTime as instants in time + methods.add_meta_method( + LuaMetaMethod::Eq, + |_, this: &Self, other: LuaUserDataRef| Ok(this.eq(&other)), + ); + methods.add_meta_method( + LuaMetaMethod::Lt, + |_, this: &Self, other: LuaUserDataRef| { + Ok(matches!(this.cmp(&other), Ordering::Less)) + }, + ); + methods.add_meta_method( + LuaMetaMethod::Le, + |_, this: &Self, other: LuaUserDataRef| { + Ok(matches!(this.cmp(&other), Ordering::Less | Ordering::Equal)) + }, + ); + // Normal methods + methods.add_method("toIsoDate", |_, this, ()| Ok(this.to_iso_date())); + methods.add_method( + "formatUniversalTime", + |_, this, (format, locale): (Option, Option)| { + Ok(this.format_string_universal(format.as_deref(), locale.as_deref())) + }, + ); + methods.add_method( + "formatLocalTime", + |_, this, (format, locale): (Option, Option)| { + Ok(this.format_string_local(format.as_deref(), locale.as_deref())) + }, + ); + methods.add_method("toUniversalTime", |_, this: &Self, ()| { + Ok(this.to_universal_time()) + }); + methods.add_method("toLocalTime", |_, this: &Self, ()| Ok(this.to_local_time())); + } +} diff --git a/crates/lune-std-datetime/src/lib.rs b/crates/lune-std-datetime/src/lib.rs index 5a1dfae..83af413 100644 --- a/crates/lune-std-datetime/src/lib.rs +++ b/crates/lune-std-datetime/src/lib.rs @@ -4,6 +4,12 @@ use mlua::prelude::*; use lune_utils::TableBuilder; +mod date_time; +mod result; +mod values; + +use self::date_time::DateTime; + /** Creates the `datetime` standard library module. @@ -12,5 +18,19 @@ use lune_utils::TableBuilder; Errors when out of memory. */ pub fn module(lua: &Lua) -> LuaResult { - TableBuilder::new(lua)?.build_readonly() + TableBuilder::new(lua)? + .with_function("fromIsoDate", |_, iso_date: String| { + Ok(DateTime::from_iso_date(iso_date)?) + })? + .with_function("fromLocalTime", |_, values| { + Ok(DateTime::from_local_time(&values)?) + })? + .with_function("fromUniversalTime", |_, values| { + Ok(DateTime::from_universal_time(&values)?) + })? + .with_function("fromUnixTimestamp", |_, timestamp| { + Ok(DateTime::from_unix_timestamp_float(timestamp)?) + })? + .with_function("now", |_, ()| Ok(DateTime::now()))? + .build_readonly() } diff --git a/crates/lune-std-datetime/src/result.rs b/crates/lune-std-datetime/src/result.rs new file mode 100644 index 0000000..b79fb40 --- /dev/null +++ b/crates/lune-std-datetime/src/result.rs @@ -0,0 +1,32 @@ +use mlua::prelude::*; + +use thiserror::Error; + +pub type DateTimeResult = Result; + +#[derive(Debug, Clone, Error)] +pub enum DateTimeError { + #[error("invalid date")] + InvalidDate, + #[error("invalid time")] + InvalidTime, + #[error("ambiguous date or time")] + Ambiguous, + #[error("date or time is outside allowed range")] + OutOfRangeUnspecified, + #[error("{name} must be within range {min} -> {max}, got {value}")] + OutOfRange { + name: &'static str, + value: String, + min: String, + max: String, + }, + #[error(transparent)] + ParseError(#[from] chrono::ParseError), +} + +impl From for LuaError { + fn from(value: DateTimeError) -> Self { + LuaError::runtime(value.to_string()) + } +} diff --git a/crates/lune-std-datetime/src/values.rs b/crates/lune-std-datetime/src/values.rs new file mode 100644 index 0000000..4193d63 --- /dev/null +++ b/crates/lune-std-datetime/src/values.rs @@ -0,0 +1,170 @@ +use mlua::prelude::*; + +use chrono::prelude::*; + +use lune_utils::TableBuilder; + +use super::result::{DateTimeError, DateTimeResult}; + +#[derive(Debug, Clone, Copy)] +pub struct DateTimeValues { + pub year: i32, + pub month: u32, + pub day: u32, + pub hour: u32, + pub minute: u32, + pub second: u32, + pub millisecond: u32, +} + +impl DateTimeValues { + /** + Verifies that all of the date & time values are within allowed ranges: + + | Name | Range | + |---------------|----------------| + | `year` | `1400 -> 9999` | + | `month` | `1 -> 12` | + | `day` | `1 -> 31` | + | `hour` | `0 -> 23` | + | `minute` | `0 -> 59` | + | `second` | `0 -> 60` | + | `millisecond` | `0 -> 999` | + */ + pub fn verify(self) -> DateTimeResult { + verify_in_range("year", self.year, 1400, 9999)?; + verify_in_range("month", self.month, 1, 12)?; + verify_in_range("day", self.day, 1, 31)?; + verify_in_range("hour", self.hour, 0, 23)?; + verify_in_range("minute", self.minute, 0, 59)?; + verify_in_range("second", self.second, 0, 60)?; + verify_in_range("millisecond", self.millisecond, 0, 999)?; + Ok(self) + } +} + +fn verify_in_range(name: &'static str, value: T, min: T, max: T) -> DateTimeResult +where + T: PartialOrd + std::fmt::Display, +{ + assert!(max > min); + if value < min || value > max { + Err(DateTimeError::OutOfRange { + name, + min: min.to_string(), + max: max.to_string(), + value: value.to_string(), + }) + } else { + Ok(value) + } +} + +/** + Conversion methods between `DateTimeValues` and plain lua tables + + Note that the `IntoLua` implementation here uses a read-only table, + since we generally want to convert into lua when we know we have + a fixed point in time, and we guarantee that it doesn't change +*/ + +impl FromLua<'_> for DateTimeValues { + fn from_lua(value: LuaValue, _: &Lua) -> LuaResult { + if !value.is_table() { + return Err(LuaError::FromLuaConversionError { + from: value.type_name(), + to: "DateTimeValues", + message: Some("value must be a table".to_string()), + }); + }; + + let value = value.as_table().unwrap(); + let values = Self { + year: value.get("year")?, + month: value.get("month")?, + day: value.get("day")?, + hour: value.get("hour")?, + minute: value.get("minute")?, + second: value.get("second")?, + millisecond: value.get("millisecond").unwrap_or(0), + }; + + match values.verify() { + Ok(dt) => Ok(dt), + Err(e) => Err(LuaError::FromLuaConversionError { + from: "table", + to: "DateTimeValues", + message: Some(e.to_string()), + }), + } + } +} + +impl IntoLua<'_> for DateTimeValues { + fn into_lua(self, lua: &Lua) -> LuaResult { + let tab = TableBuilder::new(lua)? + .with_value("year", self.year)? + .with_values(vec![ + ("month", self.month), + ("day", self.day), + ("hour", self.hour), + ("minute", self.minute), + ("second", self.second), + ("millisecond", self.millisecond), + ])? + .build_readonly()?; + Ok(LuaValue::Table(tab)) + } +} + +/** + Conversion methods between chrono's timezone-aware `DateTime` to + and from our non-timezone-aware `DateTimeValues` values struct +*/ + +impl From> for DateTimeValues { + fn from(value: DateTime) -> Self { + Self { + year: value.year(), + month: value.month(), + day: value.day(), + hour: value.hour(), + minute: value.minute(), + second: value.second(), + millisecond: value.timestamp_subsec_millis(), + } + } +} + +impl TryFrom for DateTime { + type Error = DateTimeError; + fn try_from(value: DateTimeValues) -> Result { + Utc.with_ymd_and_hms( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + ) + .single() + .ok_or(DateTimeError::Ambiguous) + } +} + +impl TryFrom for DateTime { + type Error = DateTimeError; + fn try_from(value: DateTimeValues) -> Result { + Local + .with_ymd_and_hms( + value.year, + value.month, + value.day, + value.hour, + value.minute, + value.second, + ) + .single() + .ok_or(DateTimeError::Ambiguous) + } +}