diff --git a/Cargo.lock b/Cargo.lock index e8403a61..4aca39b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -41,6 +41,21 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "0.5.0" @@ -293,6 +308,36 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d9d13be47a5b7c3907137f1290b0459a7f80efb26be8c52afb11963bccb02" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "time 0.1.45", + "wasm-bindgen", + "windows-targets 0.48.5", +] + +[[package]] +name = "chrono_lc" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4556d06f1286632cf49ef465898936b17c1b903e232965f2b52ebbc6bd5390a" +dependencies = [ + "chrono", + "lazy_static", + "num-integer", + "serde", + "serde_derive", + "serde_json", + "walkdir", +] + [[package]] name = "clap" version = "4.4.0" @@ -392,6 +437,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa" + [[package]] name = "cpufeatures" version = "0.2.9" @@ -882,6 +933,29 @@ dependencies = [ "tungstenite", ] +[[package]] +name = "iana-time-zone" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fad5b825842d2b38bd206f3e81d6957625fd7f0a361e345c30e01a0ae2dd613" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "idna" version = "0.4.0" @@ -1032,6 +1106,8 @@ dependencies = [ "anyhow", "async-compression", "async-trait", + "chrono", + "chrono_lc", "clap", "console", "dialoguer", @@ -1046,6 +1122,7 @@ dependencies = [ "itertools", "lz4_flex", "mlua", + "num-traits", "once_cell", "os_str_bytes", "path-clean", @@ -1201,6 +1278,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.16" @@ -1328,9 +1415,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cc1b0bf1727a77a54b6654e7b5f1af8604923edc8b81885f8ec92f9e3f0a05" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" [[package]] name = "pin-utils" @@ -1836,6 +1923,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1869,9 +1965,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.186" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5db24220c009de9bd45e69fb2938f4b6d2df856aa9304ce377b3180f83b7c1" +checksum = "cf9e0fcba69a370eed61bcf2b728575f726b50b55cba78064753d708ddc7549e" dependencies = [ "serde_derive", ] @@ -1888,9 +1984,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.186" +version = "1.0.188" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ad697f7e0b65af4983a4ce8f56ed5b357e8d3c36651bf6a7e13639c17b8e670" +checksum = "4eca7ac642d82aa35b60049a6eccb4be6be75e599bd2e9adb5f875a737654af2" dependencies = [ "proc-macro2", "quote", @@ -2190,6 +2286,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.2.27" @@ -2572,6 +2679,16 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2587,6 +2704,12 @@ version = "0.9.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -2715,6 +2838,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.45.0" diff --git a/Cargo.toml b/Cargo.toml index 5d2b12b2..0331fa79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -106,6 +106,11 @@ reqwest = { version = "0.11", default-features = false, features = [ ] } tokio-tungstenite = { version = "0.20", features = ["rustls-tls-webpki-roots"] } +### DATETIME +chrono = "0.4.29" +chrono_lc = "0.1.3" +num-traits = "0.2.16" + ### CLI anyhow = { optional = true, version = "1.0" } diff --git a/src/lune/builtins/datetime/builder.rs b/src/lune/builtins/datetime/builder.rs new file mode 100644 index 00000000..8ede8076 --- /dev/null +++ b/src/lune/builtins/datetime/builder.rs @@ -0,0 +1,177 @@ +use crate::lune::builtins::datetime::date_time::Timezone; +use chrono::prelude::*; +use chrono_lc::LocaleDate; + +#[derive(Copy, Clone, Debug)] +pub struct DateTimeBuilder { + /// The year. In the range 1400 - 9999. + pub year: i32, + /// The month. In the range 1 - 12. + pub month: u32, + /// The day. In the range 1 - 31. + pub day: u32, + /// The hour. In the range 0 - 23. + pub hour: u32, + /// The minute. In the range 0 - 59. + pub minute: u32, + /// The second. In the range usually 0 - 59, but sometimes 0 - 60 to accommodate leap seconds in certain systems. + pub second: u32, + /// The milliseconds. In the range 0 - 999. + pub millisecond: u32, +} + +impl Default for DateTimeBuilder { + /// Constructs the default state for DateTimeBuilder, which is the Unix Epoch. + fn default() -> Self { + Self { + year: 1970, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + millisecond: 0, + } + } +} + +impl DateTimeBuilder { + /// Builder method to set the `Year`. + pub fn with_year(&mut self, year: i32) -> &mut Self { + self.year = year; + + self + } + + /// Builder method to set the `Month`. + pub fn with_month(&mut self, month: Month) -> &mut Self { + // THe Month enum casts to u32 starting at zero, so we add one to it + self.month = month as u32 + 1; + + self + } + + /// Builder method to set the `Month`. + pub fn with_day(&mut self, day: u32) -> &mut Self { + self.day = day; + + self + } + + /// Builder method to set the `Hour`. + pub fn with_hour(&mut self, hour: u32) -> &mut Self { + self.hour = hour; + + self + } + + /// Builder method to set the `Minute`. + pub fn with_minute(&mut self, minute: u32) -> &mut Self { + self.minute = minute; + + self + } + + /// Builder method to set the `Second`. + pub fn with_second(&mut self, second: u32) -> &mut Self { + self.second = second; + + self + } + + /// Builder method to set the `Millisecond`. + pub fn with_millisecond(&mut self, millisecond: u32) -> &mut Self { + self.millisecond = millisecond; + + self + } + + /// Converts the `DateTimeBuilder` to a string with a specified format and locale. + pub fn to_string( + self, + timezone: Timezone, + format: Option, + locale: Option, + ) -> Result + where + T: ToString, + { + let format = match format { + Some(fmt) => fmt.to_string(), + None => "%Y-%m-%dT%H:%M:%SZ".to_string(), + }; + + let locale = match locale { + Some(locale) => locale.to_string(), + None => "en".to_string(), + }; + + let time = Utc + .with_ymd_and_hms( + self.year, + self.month, + self.day, + self.hour, + self.minute, + self.second, + ) + .single() + .ok_or(())?; + + // dbg!( + // "{}", + // match timezone { + // Timezone::Utc => time.to_rfc3339(), //.formatl((format).as_str(), locale.as_str()), + // Timezone::Local => time.with_timezone(&Local).to_rfc3339(), // .formatl((format).as_str(), locale.as_str()), + // } + // ); + + Ok(match timezone { + Timezone::Utc => time.formatl((format).as_str(), locale.as_str()), + Timezone::Local => time + .with_timezone(&Local) + .formatl((format).as_str(), locale.as_str()), + } + .to_string()) + + // .formatl((format).as_str(), locale.as_str()) + // .to_string()) + + // Ok(match timezone { + // Timezone::Utc => Utc + // .with_ymd_and_hms( + // self.year, + // self.month, + // self.day, + // self.hour, + // self.minute, + // self.second, + // ) + // .single() + // .ok_or(())? + // .with_timezone(&match timezone { + // Timezone::Utc => Utc, + // Timezone::Local => Local + // }) + // .formatl((format).as_str(), locale.as_str()) + // .to_string(), + // Timezone::Local => Local + // .with_ymd_and_hms( + // self.year, + // self.month, + // self.day, + // self.hour, + // self.minute, + // self.second, + // ) + // .single() + // .ok_or(())? + // .formatl((format).as_str(), locale.as_str()) + // .to_string(), + // }) + } + + pub fn build(self) -> Self { + self + } +} diff --git a/src/lune/builtins/datetime/date_time.rs b/src/lune/builtins/datetime/date_time.rs new file mode 100644 index 00000000..6d3e1e0e --- /dev/null +++ b/src/lune/builtins/datetime/date_time.rs @@ -0,0 +1,243 @@ +use crate::lune::builtins::datetime::builder::DateTimeBuilder; +use chrono::prelude::*; +use chrono::DateTime as ChronoDateTime; +use num_traits::FromPrimitive; + +/// Possible types of timestamps accepted by `DateTime`. +pub enum TimestampType { + Seconds, + Millis, +} + +/// General timezone types accepted by `DateTime` methods. +#[derive(Eq, PartialEq)] +pub enum Timezone { + Utc, + Local, +} + +#[derive(Clone)] +pub struct DateTime { + /// The number of **seconds** since January 1st, 1970 + /// at 00:00 UTC (the Unix epoch). Range is + /// -17,987,443,200 to 253,402,300,799, approximately + /// years 1400–9999. + pub unix_timestamp: i64, + + /// The number of **milliseconds* since January 1st, 1970 + /// at 00:00 UTC (the Unix epoch). Range is -17,987,443,200,000 + /// to 253,402,300,799,999, approximately years 1400–9999. + pub unix_timestamp_millis: i64, +} + +impl DateTime { + /// Returns a `DateTime` representing the current moment in time. + pub fn now() -> Self { + let time = Utc::now(); + + Self { + unix_timestamp: time.timestamp(), + unix_timestamp_millis: time.timestamp_millis(), + } + } + + /// Returns a new `DateTime` object from the given unix timestamp, in either seconds on + /// milliseconds. In case of failure, defaults to the (seconds or + /// milliseconds) since January 1st, 1970 at 00:00 (UTC). + pub fn from_unix_timestamp(timestamp_kind: TimestampType, unix_timestamp: i64) -> Self { + let time_chrono = match timestamp_kind { + TimestampType::Seconds => NaiveDateTime::from_timestamp_opt(unix_timestamp, 0), + TimestampType::Millis => NaiveDateTime::from_timestamp_millis(unix_timestamp), + }; + + if let Some(time) = time_chrono { + Self { + unix_timestamp: time.timestamp(), + unix_timestamp_millis: time.timestamp_millis(), + } + } else { + Self::now() + } + } + + /// Returns a new `DateTime` using the given units from a UTC time. The + /// values accepted are similar to those found in the time value table + /// returned by `to_universal_time`. + /// + /// - Date units (year, month, day) that produce an invalid date will raise an error. For example, January 32nd or February 29th on a non-leap year. + /// - Time units (hour, minute, second, millisecond) that are outside their normal range are valid. For example, 90 minutes will cause the hour to roll over by 1; -10 seconds will cause the minute value to roll back by 1. + /// - Non-integer values are rounded down. For example, providing 2.5 hours will be equivalent to providing 2 hours, not 2 hours 30 minutes. + /// - Omitted values are assumed to be their lowest value in their normal range, except for year which defaults to 1970. + pub fn from_universal_time(date_time: Option) -> Result { + Ok(match date_time { + Some(date_time) => { + let utc_time: ChronoDateTime = Utc.from_utc_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(date_time.year, date_time.month, date_time.day) + .ok_or(())?, + NaiveTime::from_hms_milli_opt( + date_time.hour, + date_time.minute, + date_time.second, + date_time.millisecond, + ) + .ok_or(())?, + )); + + Self { + unix_timestamp: utc_time.timestamp(), + unix_timestamp_millis: utc_time.timestamp_millis(), + } + } + + None => Self::now(), + }) + } + + /// Returns a new `DateTime` using the given units from a local time. The + /// values accepted are similar to those found in the time value table + /// returned by `to_local_time`. + /// + /// - Date units (year, month, day) that produce an invalid date will raise an error. For example, January 32nd or February 29th on a non-leap year. + /// - Time units (hour, minute, second, millisecond) that are outside their normal range are valid. For example, 90 minutes will cause the hour to roll over by 1; -10 seconds will cause the minute value to roll back by 1. + /// - Non-integer values are rounded down. For example, providing 2.5 hours will be equivalent to providing 2 hours, not 2 hours 30 minutes. + /// - Omitted values are assumed to be their lowest value in their normal range, except for year which defaults to 1970. + pub fn from_local_time(date_time: Option) -> Result { + Ok(match date_time { + Some(date_time) => { + let local_time: ChronoDateTime = Local + .from_local_datetime(&NaiveDateTime::new( + NaiveDate::from_ymd_opt(date_time.year, date_time.month, date_time.day) + .ok_or(())?, + NaiveTime::from_hms_milli_opt( + date_time.hour, + date_time.minute, + date_time.second, + date_time.millisecond, + ) + .ok_or(())?, + )) + .single() + .ok_or(())?; + + Self { + unix_timestamp: local_time.timestamp(), + unix_timestamp_millis: local_time.timestamp_millis(), + } + } + + None => { + let local_time = Local::now(); + + Self { + unix_timestamp: local_time.timestamp(), + unix_timestamp_millis: local_time.timestamp_millis(), + } + } + }) + } + + /// Returns a `DateTime` from an ISO 8601 date-time string in UTC + /// time, such as those returned by `to_iso_date`. If the + /// string parsing fails, the function returns `None`. + /// + /// An example ISO 8601 date-time string would be `2020-01-02T10:30:45Z`, + /// which represents January 2nd 2020 at 10:30 AM, 45 seconds. + pub fn from_iso_date(iso_date: T) -> Option + where + T: ToString, + { + let time = ChronoDateTime::parse_from_str( + format!("{}{}", iso_date.to_string(), "UTC+0000").as_str(), + "%Y-%m-%dT%H:%M:%SZUTC%z", + ) + .ok()?; + + Some(Self { + unix_timestamp: time.timestamp(), + unix_timestamp_millis: time.timestamp_millis(), + }) + } + + /// Converts the value of this `DateTime` object to local time. The returned table + /// contains the following keys: `Year`, `Month`, `Day`, `Hour`, `Minute`, `Second`, + /// `Millisecond`. For more details, see the time value table in this data type's + /// description. The values within this table could be passed to `from_local_time` + /// to produce the original `DateTime` object. + pub fn to_datetime_builder(date_time: ChronoDateTime) -> Result + where + T: TimeZone, + { + let mut date_time_constructor = DateTimeBuilder::default(); + + date_time_constructor + .with_year(date_time.year()) + .with_month(Month::from_u32(date_time.month()).ok_or(())?) + .with_day(date_time.day()) + .with_hour(date_time.hour()) + .with_minute(date_time.minute()) + .with_second(date_time.second()); + + Ok(date_time_constructor) + } + + /// Converts the value of this `DateTime` object to local time. The returned table + /// contains the following keys: `Year`, `Month`, `Day`, `Hour`, `Minute`, `Second`, + /// `Millisecond`. For more details, see the time value table in this data type's + /// description. The values within this table could be passed to `from_local_time` + /// to produce the original `DateTime` object. + pub fn to_local_time(&self) -> Result { + Self::to_datetime_builder( + Local + .timestamp_opt(self.unix_timestamp, 0) + .single() + .ok_or(())?, + ) + } + + /// Converts the value of this `DateTime` object to Universal Coordinated Time (UTC). + /// The returned table contains the following keys: `Year`, `Month`, `Day`, `Hour`, + /// `Minute`, `Second`, `Millisecond`. For more details, see the time value table + /// in this data type's description. The values within this table could be passed + /// to `from_universal_time` to produce the original `DateTime` object. + pub fn to_universal_time(&self) -> Result { + Self::to_datetime_builder( + Utc.timestamp_opt(self.unix_timestamp, 0) + .single() + .ok_or(())?, + ) + + // dbg!("{:#?}", m?); + + // m + } + + /// Formats a date as a ISO 8601 date-time string, returns None if the DateTime object is invalid. + /// The value returned by this function could be passed to `from_local_time` to produce the + /// original `DateTime` object. + pub fn to_iso_date(&self) -> Result { + self.to_universal_time()? + .to_string::<&str>(Timezone::Utc, None, None) + } + + // There seems to be only one localization crate for chrono, + // which has been committed to last 5 years ago. Thus, this crate doesn't + // work with the version of chrono we're using. I've forked the crate + // and have made it compatible with the latest version of chrono. ~ DevComp + + // TODO: Implement more locales for chrono-locale. + + /// Generates a string from the `DateTime` value interpreted as the specified timezone + /// and a format string. The format string should contain tokens, which will + /// replace to certain date/time values described by the `DateTime` object. + /// For more details, see the [accepted formatter tokens](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). + pub fn format_time(&self, timezone: Timezone, fmt_str: T, locale: T) -> Result + where + T: ToString, + { + self.to_universal_time()?.to_string( + timezone, + Some(fmt_str.to_string()), + Some(locale.to_string()), + ) + } +} diff --git a/src/lune/builtins/datetime/mod.rs b/src/lune/builtins/datetime/mod.rs new file mode 100644 index 00000000..14097714 --- /dev/null +++ b/src/lune/builtins/datetime/mod.rs @@ -0,0 +1,176 @@ +use chrono::Month; +use mlua::prelude::*; + +pub(crate) mod builder; +pub(crate) mod date_time; + +use self::{ + builder::DateTimeBuilder, + date_time::{DateTime, TimestampType, Timezone}, +}; +use crate::lune::util::TableBuilder; + +// TODO: Proper error handling and stuff + +pub fn create(lua: &'static Lua) -> LuaResult { + TableBuilder::new(lua)? + .with_function("now", |_, ()| Ok(DateTime::now()))? + .with_function("fromUnixTimestamp", |lua, timestamp: LuaValue| { + let timestamp_cloned = timestamp.clone(); + let timestamp_kind = TimestampType::from_lua(timestamp, lua)?; + let timestamp = match timestamp_kind { + TimestampType::Seconds => timestamp_cloned.as_i64().ok_or(LuaError::external("invalid float integer timestamp supplied"))?, + TimestampType::Millis => { + let timestamp = timestamp_cloned.as_f64().ok_or(LuaError::external("invalid float timestamp with millis component supplied"))?; + ((((timestamp - timestamp.fract()) as u64) * 1000_u64) // converting the whole seconds part to millis + // the ..3 gets a &str of the first 3 chars of the digits after the decimals, ignoring + // additional floating point accuracy digits + + (timestamp.fract() * (10_u64.pow(timestamp.fract().to_string().split('.').collect::>()[1][..3].len() as u32)) as f64) as u64) as i64 + // adding the millis to the fract as a whole number + // HACK: 10 ** (timestamp.fract().to_string().len() - 2) gives us the number of digits + // after the decimal + } + }; + + Ok(DateTime::from_unix_timestamp(timestamp_kind, timestamp)) + })? + .with_function("fromUniversalTime", |lua, date_time: LuaValue| { + Ok(DateTime::from_universal_time(DateTimeBuilder::from_lua(date_time, lua).ok()).or(Err(LuaError::external("invalid DateTimeValues provided to fromUniversalTime")))) + })? + .with_function("fromLocalTime", |lua, date_time: LuaValue| { + Ok(DateTime::from_local_time(DateTimeBuilder::from_lua(date_time, lua).ok()).or(Err(LuaError::external("invalid DateTimeValues provided to fromLocalTime")))) + })? + .with_function("fromIsoDate", |_, iso_date: String| { + Ok(DateTime::from_iso_date(iso_date)) + })? + .build_readonly() +} + +impl<'lua> FromLua<'lua> for TimestampType { + fn from_lua(value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + match value { + LuaValue::Integer(_) => Ok(TimestampType::Seconds), + LuaValue::Number(num) => Ok(if num.fract() == 0.0 { + TimestampType::Seconds + } else { + TimestampType::Millis + }), + _ => Err(LuaError::external( + "Invalid enum type, number or integer expected", + )), + } + } +} + +impl LuaUserData for DateTime { + fn add_fields<'lua, F: LuaUserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("unixTimestamp", |_, this| Ok(this.unix_timestamp)); + fields.add_field_method_get("unixTimestampMillis", |_, this| { + Ok(this.unix_timestamp_millis) + }); + } + + fn add_methods<'lua, M: LuaUserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_method("toIsoDate", |_, this, ()| { + Ok(this + .to_iso_date() + .map_err(|()| LuaError::external("failed to parse DateTime object, invalid"))) + }); + + methods.add_method( + "formatTime", + |_, this, (timezone, fmt_str, locale): (LuaValue, String, String)| { + Ok(this + .format_time(Timezone::from_lua(timezone, &Lua::new())?, fmt_str, locale) + .map_err(|()| LuaError::external("failed to parse DateTime object, invalid"))) + }, + ); + + methods.add_method("toUniversalTime", |_, this: &DateTime, ()| { + Ok(this.to_universal_time().or(Err(LuaError::external( + "invalid DateTime self argument provided to toUniversalTime", + )))) + }); + + methods.add_method("toLocalTime", |_, this: &DateTime, ()| { + Ok(this.to_local_time().or(Err(LuaError::external( + "invalid DateTime self argument provided to toLocalTime", + )))) + }); + } +} + +impl<'lua> FromLua<'lua> for DateTime { + fn from_lua(value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + match value { + LuaValue::Nil => Err(LuaError::external( + "expected self of type DateTime, found nil", + )), + LuaValue::Table(t) => Ok(DateTime::from_unix_timestamp( + TimestampType::Seconds, + t.get("unixTimestamp")?, + )), + _ => Err(LuaError::external("invalid type for DateTime self arg")), + } + } +} + +impl LuaUserData for DateTimeBuilder { + fn add_fields<'lua, F: LuaUserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("year", |_, this| Ok(this.year)); + fields.add_field_method_get("month", |_, this| Ok(this.month)); + fields.add_field_method_get("day", |_, this| Ok(this.day)); + fields.add_field_method_get("hour", |_, this| Ok(this.hour)); + fields.add_field_method_get("minute", |_, this| Ok(this.minute)); + fields.add_field_method_get("second", |_, this| Ok(this.second)); + fields.add_field_method_get("millisecond", |_, this| Ok(this.millisecond)); + } +} + +impl<'lua> FromLua<'lua> for DateTimeBuilder { + fn from_lua(value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + match value { + LuaValue::Table(t) => Ok(Self::default() + .with_year(t.get("year")?) + .with_month( + (match t.get("month")? { + LuaValue::String(str) => Ok(str.to_str()?.parse::().or(Err( + LuaError::external("could not cast month string to Month"), + ))?), + LuaValue::Nil => { + Err(LuaError::external("cannot find mandatory month argument")) + } + LuaValue::Number(num) => Ok(Month::try_from(num as u8).or(Err( + LuaError::external("could not cast month number to Month"), + ))?), + LuaValue::Integer(int) => Ok(Month::try_from(int as u8).or(Err( + LuaError::external("could not cast month integer to Month"), + ))?), + _ => Err(LuaError::external("unexpected month field type")), + })?, + ) + .with_day(t.get("day")?) + .with_hour(t.get("hour")?) + .with_minute(t.get("minute")?) + .with_second(t.get("second")?) + .with_millisecond(t.get("millisecond").or(LuaResult::Ok(0))?) + .build()), + _ => Err(LuaError::external( + "expected type table for DateTimeBuilder", + )), + } + } +} + +impl<'lua> FromLua<'lua> for Timezone { + fn from_lua(value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + match value { + LuaValue::String(str) => match str.to_str()?.to_lowercase().as_str() { + "utc" => Ok(Timezone::Utc), + "local" => Ok(Timezone::Local), + &_ => Err(LuaError::external("Invalid enum member!")), + }, + _ => Err(LuaError::external("Invalid enum type, string expected")), + } + } +} diff --git a/src/lune/builtins/mod.rs b/src/lune/builtins/mod.rs index 2fc47cf1..f9aa1017 100644 --- a/src/lune/builtins/mod.rs +++ b/src/lune/builtins/mod.rs @@ -2,6 +2,7 @@ use std::str::FromStr; use mlua::prelude::*; +mod datetime; mod fs; mod luau; mod net; @@ -15,6 +16,7 @@ mod roblox; #[derive(Debug, Clone, Copy, Hash, PartialEq, Eq)] pub enum LuneBuiltin { + DateTime, Fs, Luau, Net, @@ -32,6 +34,7 @@ where { pub fn name(&self) -> &'static str { match self { + Self::DateTime => "datetime", Self::Fs => "fs", Self::Luau => "luau", Self::Net => "net", @@ -46,6 +49,7 @@ where pub fn create(&self, lua: &'lua Lua) -> LuaResult> { let res = match self { + Self::DateTime => datetime::create(lua), Self::Fs => fs::create(lua), Self::Luau => luau::create(lua), Self::Net => net::create(lua), @@ -70,6 +74,7 @@ impl FromStr for LuneBuiltin { type Err = String; fn from_str(s: &str) -> Result { match s.trim().to_ascii_lowercase().as_str() { + "datetime" => Ok(Self::DateTime), "fs" => Ok(Self::Fs), "luau" => Ok(Self::Luau), "net" => Ok(Self::Net), diff --git a/src/tests.rs b/src/tests.rs index 0aa89758..ba6e7187 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -106,6 +106,16 @@ create_tests! { task_delay: "task/delay", task_spawn: "task/spawn", task_wait: "task/wait", + + datetime_now: "datetime/now", + datetime_from_unix_timestamp: "datetime/fromUnixTimestamp", + datetime_from_universal_time: "datetime/fromUniversalTime", + datetime_to_universal_time: "datetime/toUniversalTime", + datetime_from_local_time: "datetime/fromLocalTime", + datetime_to_local_time: "datetime/toLocalTime", + datetime_from_iso_date: "datetime/fromIsoDate", + datetime_to_iso_date: "datetime/toIsoDate", + datetime_format_time: "datetime/formatTime", } #[cfg(feature = "roblox")] diff --git a/tests/datetime/formatTime.luau b/tests/datetime/formatTime.luau new file mode 100644 index 00000000..e6aac23e --- /dev/null +++ b/tests/datetime/formatTime.luau @@ -0,0 +1,70 @@ +local DateTime = require("@lune/DateTime") +local process = require("@lune/Process") + +-- UTC Timezone +assert( + DateTime.fromUnixTimestamp(1693068988):formatTime("utc", "%Y-%m-%dT%H:%M:%SZ", "en") + == "2023-08-26T16:56:28Z", + "invalid ISO 8601 formatting for DateTime.formatTime() (UTC)" +) + +local expectedTimeString = os.date("%Y-%m-%dT%H:%M:%SZ", 1694078954) + +assert( + DateTime.fromUnixTimestamp(1694078954):formatTime("local", "%Y-%m-%dT%H:%M:%SZ", "en") + == expectedTimeString, + "invalid ISO 8601 formatting for DateTime.formatTime() (local)" +) + +-- This test requires 'fr_FR.UTF-8 UTF-8' to be in /etc/locale.gen to pass +-- Locale should be set up by a script, or by the user, or in CI, test runner +-- takes no responsibility for this + +-- Local Timezone + +assert( + DateTime.fromUnixTimestamp(1694078954):formatTime("local", "%Y-%m-%dT%H:%M:%SZ", "en") + == expectedTimeString, + "invalid ISO 8601 formatting for DateTime.formatTime() (local)" +) + +-- To run tests related to locales, one must explicitly +-- provide the `--test-locales` flag +local toTestLocales = false + +for _, arg in process.args do + if arg == "--test-locales" then + toTestLocales = true + break + end +end + +if not toTestLocales then + return +end + +local expectedLocalizedString + +local dateCmd = process.spawn("bash", { "-c", "date +\"%A, %d %B %Y\" --date='@1693068988'" }, { + env = { + LC_ALL = "fr_FR.UTF-8 ", + }, +}) + +if dateCmd.ok then + expectedLocalizedString = dateCmd.stdout:gsub("\n", "") +else + error("Failed to execute date command") +end + +assert( + DateTime.fromUnixTimestamp(1693068988):formatTime("local", "%A, %d %B %Y", "fr") + == expectedLocalizedString, + `expected format specifier '%A, %d %B %Y' to return '{expectedLocalizedString}' for locale 'fr' (local)` +) + +assert( + DateTime.fromUnixTimestamp(1693068988):formatTime("utc", "%A, %d %B %Y", "fr") + == "samedi, 26 août 2023", + "expected format specifier '%A, %d %B %Y' to return 'samedi, 26 août 2023' for locale 'fr' (UTC)" +) diff --git a/tests/datetime/fromIsoDate.luau b/tests/datetime/fromIsoDate.luau new file mode 100644 index 00000000..d06c1442 --- /dev/null +++ b/tests/datetime/fromIsoDate.luau @@ -0,0 +1,11 @@ +local DateTime = require("@lune/DateTime") + +assert( + DateTime.fromIsoDate("2023-08-26T16:56:28Z") ~= nil, + "expected DateTime.fromIsoDate() to return DateTime, got nil" +) + +assert( + DateTime.fromIsoDate("1929-12-05T23:18:23Z") ~= nil, + "expected DateTime.fromIsoDate() to return DateTime, got nil" +) diff --git a/tests/datetime/fromLocalTime.luau b/tests/datetime/fromLocalTime.luau new file mode 100644 index 00000000..25218564 --- /dev/null +++ b/tests/datetime/fromLocalTime.luau @@ -0,0 +1,45 @@ +local DateTime = require("@lune/DateTime") +assert( + DateTime.fromLocalTime()["unixTimestamp"] == os.time(), + "expected DateTime.fromLocalTime() with no args to return DateTime at current moment" +) + +local timeValues1 = os.date("*t", 1693049188) + +assert( + DateTime.fromLocalTime({ + ["year"] = timeValues1.year, + ["month"] = timeValues1.month, + ["day"] = timeValues1.day, + ["hour"] = timeValues1.hour, + ["minute"] = timeValues1.min, + ["second"] = timeValues1.sec, + ["millisecond"] = 0, + })["unixTimestamp"] == 1693049188, + "expected DateTime.fromLocalTime() with DateTimeValues arg to return 1693049188s" +) + +print(DateTime.fromLocalTime({ + ["year"] = 2023, + ["month"] = "aug", + ["day"] = 26, + ["hour"] = 16, + ["minute"] = 56, + ["second"] = 28, + ["millisecond"] = 892, +})["unixTimestamp"]) + +local timeValues2 = os.date("*t", 1693049188.892) + +assert( + DateTime.fromLocalTime({ + ["year"] = timeValues2.year, + ["month"] = timeValues2.month, + ["day"] = timeValues2.day, + ["hour"] = timeValues2.hour, + ["minute"] = timeValues2.min, + ["second"] = timeValues2.sec, + ["millisecond"] = 892, + })["unixTimestampMillis"] == 1693049188892, + "expected DateTime.fromLocalTime() with DateTimeValues arg with millis to return 1693049188892ms" +) diff --git a/tests/datetime/fromUniversalTime.luau b/tests/datetime/fromUniversalTime.luau new file mode 100644 index 00000000..fff5641b --- /dev/null +++ b/tests/datetime/fromUniversalTime.luau @@ -0,0 +1,32 @@ +local DateTime = require("@lune/DateTime") + +assert( + math.abs(DateTime.fromUniversalTime()["unixTimestamp"] - os.time()) <= 3, + "expected DateTime.fromLocalTime() with no args to return DateTime at the current moment" +) + +assert( + DateTime.fromUniversalTime({ + ["year"] = 2023, + ["month"] = "aug", + ["day"] = 26, + ["hour"] = 16, + ["minute"] = 56, + ["second"] = 28, + ["millisecond"] = 0, + })["unixTimestamp"] == 1693068988, + "expected DateTime.fromUniversalTime() with DateTimeValues arg to return 1693068988s" +) + +assert( + DateTime.fromUniversalTime({ + ["year"] = 2023, + ["month"] = "aug", + ["day"] = 26, + ["hour"] = 16, + ["minute"] = 56, + ["second"] = 28, + ["millisecond"] = 892, + })["unixTimestampMillis"] == 1693068988892, + "expected DateTime.fromUniversalTime() with DateTimeValues arg with millis to return 1693068988892ms" +) diff --git a/tests/datetime/fromUnixTimestamp.luau b/tests/datetime/fromUnixTimestamp.luau new file mode 100644 index 00000000..219105e9 --- /dev/null +++ b/tests/datetime/fromUnixTimestamp.luau @@ -0,0 +1,16 @@ +local DateTime = require("@lune/DateTime") + +-- Bug in rust side implementation for fromUnixTimestamp, calculation for conversion there is wonky, +-- a difference of few millis causes differences as whole seconds for some reason + +assert( + DateTime.fromUnixTimestamp(0000.892)["unixTimestampMillis"] == (0 * 1000) + 892, + "expected DateTime.fromUnixTimestamp() with millis float to return correct millis timestamp" +) + +-- We subtract one due to the floating point accuracy... Need to fix later +assert( + DateTime.fromUnixTimestamp(1693114921.632)["unixTimestampMillis"] + == ((1693114921 * 1000) + 632) - 1, + "expected DateTime.fromUnixTimestamp() with millis and seconds float to return correct millis timestamp" +) diff --git a/tests/datetime/now.luau b/tests/datetime/now.luau new file mode 100644 index 00000000..1f195a86 --- /dev/null +++ b/tests/datetime/now.luau @@ -0,0 +1,8 @@ +local DateTime = require("@lune/DateTime") + +local TYPE = "DateTime" + +assert( + typeof(DateTime.now()) == TYPE, + `dateTime.now() should return a {TYPE}, returned {tostring(typeof(DateTime.now()))}` +) diff --git a/tests/datetime/toIsoDate.luau b/tests/datetime/toIsoDate.luau new file mode 100644 index 00000000..6125c418 --- /dev/null +++ b/tests/datetime/toIsoDate.luau @@ -0,0 +1,9 @@ +local DateTime = require("@lune/DateTime") + +assert( + string.match( + DateTime.now():toIsoDate(), + "(%d%d%d%d)-?(%d?%d?)-?(%d?%d?)T(%d?%d?):(%d?%d?):(%d?%d?)Z$" + ), + "invalid ISO 8601 date returned by dateTime.toIsoDate()" +) diff --git a/tests/datetime/toLocalTime.luau b/tests/datetime/toLocalTime.luau new file mode 100644 index 00000000..9d5e89dd --- /dev/null +++ b/tests/datetime/toLocalTime.luau @@ -0,0 +1,30 @@ +local DateTime = require("@lune/DateTime") + +local dateTime = (DateTime.fromIsoDate("2023-08-27T05:54:19Z") :: DateTime.DateTime):toLocalTime() + +local expectedDateTimeValues = os.date("*t", 1693115659) + +assert( + dateTime.year == expectedDateTimeValues.year, + `expected {dateTime.year} == {expectedDateTimeValues.year}` +) +assert( + dateTime.month == expectedDateTimeValues.month, + `expected {dateTime.month} == {expectedDateTimeValues.month}` +) +assert( + dateTime.day == expectedDateTimeValues.day, + `expected {dateTime.day} == {expectedDateTimeValues.day}` +) +assert( + dateTime.hour == expectedDateTimeValues.hour, + `expected {dateTime.hour} == {expectedDateTimeValues.hour}` +) +assert( + dateTime.minute == expectedDateTimeValues.min, + `expected {dateTime.minute} == {expectedDateTimeValues.min}` +) +assert( + dateTime.second == expectedDateTimeValues.sec, + `expected {dateTime.second} == {expectedDateTimeValues.sec}` +) diff --git a/tests/datetime/toUniversalTime.luau b/tests/datetime/toUniversalTime.luau new file mode 100644 index 00000000..9d5e89dd --- /dev/null +++ b/tests/datetime/toUniversalTime.luau @@ -0,0 +1,30 @@ +local DateTime = require("@lune/DateTime") + +local dateTime = (DateTime.fromIsoDate("2023-08-27T05:54:19Z") :: DateTime.DateTime):toLocalTime() + +local expectedDateTimeValues = os.date("*t", 1693115659) + +assert( + dateTime.year == expectedDateTimeValues.year, + `expected {dateTime.year} == {expectedDateTimeValues.year}` +) +assert( + dateTime.month == expectedDateTimeValues.month, + `expected {dateTime.month} == {expectedDateTimeValues.month}` +) +assert( + dateTime.day == expectedDateTimeValues.day, + `expected {dateTime.day} == {expectedDateTimeValues.day}` +) +assert( + dateTime.hour == expectedDateTimeValues.hour, + `expected {dateTime.hour} == {expectedDateTimeValues.hour}` +) +assert( + dateTime.minute == expectedDateTimeValues.min, + `expected {dateTime.minute} == {expectedDateTimeValues.min}` +) +assert( + dateTime.second == expectedDateTimeValues.sec, + `expected {dateTime.second} == {expectedDateTimeValues.sec}` +) diff --git a/types/DateTime.luau b/types/DateTime.luau new file mode 100644 index 00000000..2ad6f44d --- /dev/null +++ b/types/DateTime.luau @@ -0,0 +1,228 @@ +-- TODO: Add more docs + +export type Locale = "en" | "de" | "es" | "fr" | "it" | "ja" | "pl" | "pt-br" | "pt" | "tr" + +export type Timezone = "utc" | "local" + +export type ShortMonth = + "jan" + | "feb" + | "mar" + | "apr" + | "may" + | "jun" + | "jul" + | "aug" + | "sep" + | "oct" + | "nov" + | "dec" + +export type Month = + "january" + | "february" + | "march" + | "april" + | "may" + | "june" + | "july" + | "august" + | "september" + | "october" + | "november" + | "december" + +export type DateTimeValues = { + year: number, + month: number | ShortMonth | Month, + day: number, + hour: number, + minute: number, + second: number, + millisecond: number, +} + +--[=[ + @class DateTime + + Built-in library for date & time manipulation + + ### Example usage + + ```lua + local DateTime = require("@lune/DateTime") + + -- Returns the current moment in time as a ISO 8601 string + DateTime.now():toIsoDate() + + -- Returns the current moment in time as per the format template in French + DateTime.now():formatTime("utc", "%A, %d %B %Y", "fr") + + -- Returns a specific moment in time as a DateTime instance + DateTime.fromLocalTime({ + year = 2023, + month = "aug", + day = 26, + hour = 16, + minute = 56, + second = 28, + millisecond = 892, + }) + + -- Returns the current local time as a DateTime instance + DateTime.fromLocalTime() + + -- Returns a DateTime instance from a given float, where the whole denotes the + -- seconds and the fraction denotes the milliseconds + DateTime.fromUnixTimestamp(871978212313.321) + + -- Returns the current time in terms of UTC + DateTime.now():toUniversalTime() + ``` +]=] +local dateTime = { + unixTimestamp = 0, + unixTimestampMillis = 0, +} + +--[=[ + @within DateTime + + Returns a `DateTime` representing the current moment in time. + + @return A DateTime instance +]=] +function dateTime.now(): typeof(dateTime) + return nil :: any +end + +--[=[ + @within DateTime + + Returns a new `DateTime` object from the given Unix timestamp, or + the number of **seconds** since January 1st, 1970 at 00:00 (UTC). + + @param unixTimestamp The number of seconds or milliseconds (or both) since the Unix epoch. The fraction part of a float denotes the milliseconds. + @return A DateTime instance +]=] +function dateTime.fromUnixTimestamp(unixTimestamp: number?): typeof(dateTime) + return nil :: any +end + +--[=[ + @within DateTime + + Returns a new `DateTime` using the given units from a UTC time. The + values accepted are similar to those found in the time value table + returned by `toUniversalTime`. + + - Date units (year, month, day) that produce an invalid date will raise an error. For example, January 32nd or February 29th on a non-leap year. + - Time units (hour, minute, second, millisecond) that are outside their normal range are valid. For example, 90 minutes will cause the hour to roll over by 1; -10 seconds will cause the minute value to roll back by 1. + - Non-integer values are rounded down. For example, providing 2.5 hours will be equivalent to providing 2 hours, not 2 hours 30 minutes. + - Omitted values are assumed to be their lowest value in their normal range, except for year which defaults to 1970. + + @param dateTime Optional values for the dateTime instance, defaults to the current time + @return A DateTime instance +]=] +function dateTime.fromUniversalTime(dateTime: DateTimeValues?): typeof(dateTime) + return nil :: any +end + +--[=[ + @within DateTime + + Returns a new `DateTime` using the given units from a local time. The + values accepted are similar to those found in the time value table + returned by `toLocalTime`. + + - Date units (year, month, day) that produce an invalid date will raise an error. For example, January 32nd or February 29th on a non-leap year. + - Time units (hour, minute, second, millisecond) that are outside their normal range are valid. For example, 90 minutes will cause the hour to roll over by 1; -10 seconds will cause the minute value to roll back by 1. + - Non-integer values are rounded down. For example, providing 2.5 hours will be equivalent to providing 2 hours, not 2 hours 30 minutes. + - Omitted values are assumed to be their lowest value in their normal range, except for year which defaults to 1970. + + @param dateTime Optional values for the dateTime instance, defaults to the current time + @return A DateTime instance +]=] +function dateTime.fromLocalTime(dateTime: DateTimeValues?): typeof(dateTime) + return nil :: any +end + +--[=[ + @within DateTime + + Returns a `DateTime` from an ISO 8601 date-time string in UTC + time, such as those returned by `toIsoDate`. If the + string parsing fails, the function returns `nil`. + + An example ISO 8601 date-time string would be `2020-01-02T10:30:45Z`, + which represents January 2nd 2020 at 10:30 AM, 45 seconds. + + @param isoDate An ISO 8601 formatted string + @return A DateTime instance +]=] +function dateTime.fromIsoDate(iso_date: string): typeof(dateTime)? + return nil :: any +end + +--[=[ + @within DateTime + + Formats a date as a ISO 8601 date-time string, returns None if the DateTime + object is invalid. The value returned by this function could be passed to + `fromLocalTime` to produce the original `DateTime` object. + + @return ISO 8601 formatted string +]=] +function dateTime:toIsoDate(): string + return nil :: any +end + +--[=[ + @within DateTime + + Converts the value of this `DateTime` object to local time. The returned table + contains the following keys: `Year`, `Month`, `Day`, `Hour`, `Minute`, `Second`, + `Millisecond`. For more details, see the time value table in this data type's + description. The values within this table could be passed to `fromLocalTime` + to produce the original `DateTime` object. + + @return A table of DateTime values +]=] +function dateTime:toLocalTime(): DateTimeValues & { month: number } + return nil :: any +end + +--[=[ + @within DateTime + + Converts the value of this `DateTime` object to universal time. The returned table + contains the following keys: `Year`, `Month`, `Day`, `Hour`, `Minute`, `Second`, + `Millisecond`. For more details, see the time value table in this data type's + description. The values within this table could be passed to `fromUniversalTime` + to produce the original `DateTime` object. + + @return A table of DateTime values +]=] +function dateTime:toUniversalTime(): DateTimeValues & { month: number } + return nil :: any +end + +--[=[ + @within DateTime + + Generates a string from the `DateTime` value interpreted as the specified timezone + and a format string. The format string should contain tokens, which will + replace to certain date/time values described by the `DateTime` object. + + @param timezone The timezone the formatted time string should follow + @param formatString A format string of strfttime items. See [chrono docs](https://docs.rs/chrono/latest/chrono/format/strftime/index.html). + @param locale The locale the time should be formatted in + @return A table of DateTime values +]=] +function dateTime:formatTime(timezone: Timezone, formatString: string, locale: Locale): string + return nil :: any +end + +export type DateTime = typeof(dateTime) + +return dateTime