diff --git a/Cargo.lock b/Cargo.lock index a1fa88c9..59505c32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[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.6.21" @@ -95,6 +104,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "base64" version = "0.22.1" @@ -150,6 +165,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "clap" version = "4.5.60" @@ -196,6 +222,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cranelift-bforest" version = "0.116.1" @@ -728,6 +760,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[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 = "icu_collections" version = "2.1.1" @@ -834,6 +890,7 @@ dependencies = [ name = "ilo" version = "0.10.3" dependencies = [ + "chrono", "clap", "cranelift-codegen", "cranelift-frontend", @@ -1049,6 +1106,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.17.0" @@ -1962,12 +2028,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 73b3082d..50635410 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls" clap = { version = "4", features = ["derive"] } fastrand = "2" regex = "1" +chrono = { version = "0.4", default-features = false, features = ["clock"] } [dev-dependencies] wiremock = "0.6" diff --git a/examples/datetime.ilo b/examples/datetime.ilo new file mode 100644 index 00000000..7c2afbf4 --- /dev/null +++ b/examples/datetime.ilo @@ -0,0 +1,16 @@ +-- Datetime builtins: dtfmt (epoch -> R t t), dtparse (text -> R n t). +-- UTC only. Format strings follow strftime conventions. + +-- Format a unix epoch as a date string. dtfmt returns a Result because the +-- epoch may be non-finite or out of range for chrono; the enclosing fn +-- returns R so the caller can pattern-match the result. +fmtday e:n>R t t;dtfmt e "%Y-%m-%d" + +-- Parse a date string back to an epoch. dtparse returns a Result, so the +-- enclosing fn must also return R for `!` to short-circuit on failure. +parseday s:t>R n t;v=dtparse! s "%Y-%m-%d";~v + +-- run: fmtday 0 +-- out: ~1970-01-01 +-- run: fmtday 1735689600 +-- out: ~2025-01-01 diff --git a/src/builtins.rs b/src/builtins.rs index 87c16a99..55fb715e 100644 --- a/src/builtins.rs +++ b/src/builtins.rs @@ -83,6 +83,8 @@ pub enum Builtin { Rnd, Rndn, Now, + Dtfmt, + Dtparse, // I/O Rd, @@ -202,6 +204,8 @@ impl Builtin { "rnd" => Some(Builtin::Rnd), "rndn" => Some(Builtin::Rndn), "now" => Some(Builtin::Now), + "dtfmt" => Some(Builtin::Dtfmt), + "dtparse" => Some(Builtin::Dtparse), "rd" => Some(Builtin::Rd), "rdl" => Some(Builtin::Rdl), "rdb" => Some(Builtin::Rdb), @@ -310,6 +314,8 @@ impl Builtin { Builtin::Rnd => "rnd", Builtin::Rndn => "rndn", Builtin::Now => "now", + Builtin::Dtfmt => "dtfmt", + Builtin::Dtparse => "dtparse", Builtin::Rd => "rd", Builtin::Rdl => "rdl", Builtin::Rdb => "rdb", @@ -460,6 +466,8 @@ mod tests { "inv", "det", "rdjl", + "dtfmt", + "dtparse", ]; for name in &all { let b = Builtin::from_name(name).unwrap_or_else(|| panic!("missing builtin: {name}")); diff --git a/src/interpreter/mod.rs b/src/interpreter/mod.rs index 38f28a09..3c9b8696 100644 --- a/src/interpreter/mod.rs +++ b/src/interpreter/mod.rs @@ -856,6 +856,56 @@ fn call_function(env: &mut Env, name: &str, args: Vec) -> Result { )), }; } + if builtin == Some(Builtin::Dtfmt) && args.len() == 2 { + return match (&args[0], &args[1]) { + (Value::Number(epoch), Value::Text(fmt_str)) => { + if !epoch.is_finite() { + return Ok(Value::Err(Box::new(Value::Text(format!( + "dtfmt: epoch is not finite ({epoch})" + ))))); + } + if *epoch < i64::MIN as f64 || *epoch > i64::MAX as f64 { + return Ok(Value::Err(Box::new(Value::Text(format!( + "dtfmt: epoch out of range ({epoch})" + ))))); + } + let secs = *epoch as i64; + match chrono::DateTime::::from_timestamp(secs, 0) { + Some(dt) => { + let formatted = dt.format(fmt_str.as_str()).to_string(); + Ok(Value::Ok(Box::new(Value::Text(formatted)))) + } + None => Ok(Value::Err(Box::new(Value::Text(format!( + "dtfmt: timestamp out of range ({secs})" + ))))), + } + } + _ => Err(RuntimeError::new( + "ILO-R009", + "dtfmt requires a number (epoch) and text (format)".to_string(), + )), + }; + } + if builtin == Some(Builtin::Dtparse) && args.len() == 2 { + return match (&args[0], &args[1]) { + (Value::Text(text), Value::Text(fmt_str)) => { + let parsed = chrono::NaiveDateTime::parse_from_str(text, fmt_str) + .map(|ndt| ndt.and_utc().timestamp() as f64) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(text, fmt_str) + .map(|nd| nd.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as f64) + }); + match parsed { + Ok(n) => Ok(Value::Ok(Box::new(Value::Number(n)))), + Err(e) => Ok(Value::Err(Box::new(Value::Text(format!("dtparse: {e}"))))), + } + } + _ => Err(RuntimeError::new( + "ILO-R009", + "dtparse requires two text args".to_string(), + )), + }; + } if builtin == Some(Builtin::Rnd) { if args.is_empty() { return Ok(Value::Number(fastrand::f64())); diff --git a/src/verify.rs b/src/verify.rs index 15f056a6..7099a4ec 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -312,6 +312,8 @@ const BUILTINS: &[(&str, &[&str], &str)] = &[ ("rnd", &[], "n"), ("rndn", &["n", "n"], "n"), ("now", &[], "n"), + ("dtfmt", &["n", "t"], "R t t"), + ("dtparse", &["t", "t"], "R n t"), ("env", &["t"], "R t t"), ("jpth", &["t", "t"], "R t t"), ("jdmp", &["any"], "t"), @@ -1452,6 +1454,60 @@ fn builtin_check_args( errors, ) } + "dtfmt" => { + if let Some(arg) = arg_types.first() + && !compatible(arg, &Ty::Number) + { + errors.push(VerifyError { + code: "ILO-T013", + function: func_ctx.to_string(), + message: format!("'dtfmt' first arg must be n (unix epoch), got {arg}"), + hint: None, + span, + is_warning: false, + }); + } + if let Some(arg) = arg_types.get(1) + && !compatible(arg, &Ty::Text) + { + errors.push(VerifyError { + code: "ILO-T013", + function: func_ctx.to_string(), + message: format!("'dtfmt' second arg must be t (format), got {arg}"), + hint: None, + span, + is_warning: false, + }); + } + (Ty::Result(Box::new(Ty::Text), Box::new(Ty::Text)), errors) + } + "dtparse" => { + if let Some(arg) = arg_types.first() + && !compatible(arg, &Ty::Text) + { + errors.push(VerifyError { + code: "ILO-T013", + function: func_ctx.to_string(), + message: format!("'dtparse' first arg must be t, got {arg}"), + hint: None, + span, + is_warning: false, + }); + } + if let Some(arg) = arg_types.get(1) + && !compatible(arg, &Ty::Text) + { + errors.push(VerifyError { + code: "ILO-T013", + function: func_ctx.to_string(), + message: format!("'dtparse' second arg must be t (format), got {arg}"), + hint: None, + span, + is_warning: false, + }); + } + (Ty::Result(Box::new(Ty::Number), Box::new(Ty::Text)), errors) + } "map" => { // map fn:F a b xs:L a → L b // map fn:F a c b ctx:c xs:L a → L b (closure-bind variant) diff --git a/src/vm/compile_cranelift.rs b/src/vm/compile_cranelift.rs index a58de5b8..ed6466e5 100644 --- a/src/vm/compile_cranelift.rs +++ b/src/vm/compile_cranelift.rs @@ -150,6 +150,9 @@ struct HelperFuncs { geth: FuncId, posth: FuncId, getmany: FuncId, + // Datetime + dtfmt: FuncId, + dtparse: FuncId, // AOT-specific helpers get_arena_ptr: FuncId, get_registry_ptr: FuncId, @@ -311,6 +314,8 @@ fn declare_all_helpers(module: &mut ObjectModule) -> HelperFuncs { geth: declare_helper(module, "jit_geth", 2, 1), posth: declare_helper(module, "jit_posth", 3, 1), getmany: declare_helper(module, "jit_getmany", 1, 1), + dtfmt: declare_helper(module, "jit_dtfmt", 2, 1), + dtparse: declare_helper(module, "jit_dtparse", 2, 1), // AOT-specific helpers get_arena_ptr: declare_helper(module, "jit_get_arena_ptr", 0, 1), get_registry_ptr: declare_helper(module, "jit_get_registry_ptr", 0, 1), @@ -1002,7 +1007,7 @@ fn compile_function_body( | OP_UNIQBY | OP_PARTITION | OP_FRQ | OP_NUM | OP_RGXSUB | OP_ZIP | OP_ENUMERATE | OP_RANGE | OP_WINDOW | OP_CHUNKS | OP_CUMSUM | OP_SETUNION | OP_SETINTER | OP_SETDIFF | OP_FFT | OP_IFFT | OP_TRANSPOSE | OP_MATMUL - | OP_INV | OP_SOLVE => { + | OP_INV | OP_SOLVE | OP_DTFMT | OP_DTPARSE => { non_num_write[a] = true; non_bool_write[a] = true; } @@ -3065,6 +3070,22 @@ fn compile_function_body( let result = builder.inst_results(call_inst)[0]; builder.def_var(vars[a_idx], result); } + OP_DTFMT => { + let bv = builder.use_var(vars[b_idx]); + let cv = builder.use_var(vars[c_idx]); + let fref = get_func_ref(&mut builder, module, helpers.dtfmt); + let call_inst = builder.ins().call(fref, &[bv, cv]); + let result = builder.inst_results(call_inst)[0]; + builder.def_var(vars[a_idx], result); + } + OP_DTPARSE => { + let bv = builder.use_var(vars[b_idx]); + let cv = builder.use_var(vars[c_idx]); + let fref = get_func_ref(&mut builder, module, helpers.dtparse); + let call_inst = builder.ins().call(fref, &[bv, cv]); + let result = builder.inst_results(call_inst)[0]; + builder.def_var(vars[a_idx], result); + } // ── Type predicates ── OP_ISNUM => { let bv = builder.use_var(vars[b_idx]); diff --git a/src/vm/jit_cranelift.rs b/src/vm/jit_cranelift.rs index 492340a9..b91c7fd3 100644 --- a/src/vm/jit_cranelift.rs +++ b/src/vm/jit_cranelift.rs @@ -162,6 +162,9 @@ struct HelperFuncs { solve: FuncId, inv: FuncId, det: FuncId, + // Datetime + dtfmt: FuncId, + dtparse: FuncId, } fn declare_helper(module: &mut JITModule, name: &str, n_params: usize, n_returns: usize) -> FuncId { @@ -312,6 +315,8 @@ fn register_helpers(builder: &mut JITBuilder) { ("jit_solve", jit_solve as *const u8), ("jit_inv", jit_inv as *const u8), ("jit_det", jit_det as *const u8), + ("jit_dtfmt", jit_dtfmt as *const u8), + ("jit_dtparse", jit_dtparse as *const u8), ]; for &(name, ptr) in helpers { builder.symbol(name, ptr); @@ -454,6 +459,8 @@ fn declare_all_helpers(module: &mut JITModule) -> HelperFuncs { solve: declare_helper(module, "jit_solve", 2, 1), inv: declare_helper(module, "jit_inv", 1, 1), det: declare_helper(module, "jit_det", 1, 1), + dtfmt: declare_helper(module, "jit_dtfmt", 2, 1), + dtparse: declare_helper(module, "jit_dtparse", 2, 1), } } @@ -1025,7 +1032,7 @@ fn compile_function_body( | OP_RECNEW | OP_RECWITH | OP_PRT | OP_RD | OP_RDL | OP_WR | OP_WRL | OP_TRM | OP_UPR | OP_LWR | OP_CAP | OP_PADL | OP_PADR | OP_UNQ | OP_UNIQBY | OP_PARTITION | OP_FRQ | OP_NUM - | OP_RGXSUB | OP_TRANSPOSE | OP_MATMUL => { + | OP_RGXSUB | OP_TRANSPOSE | OP_MATMUL | OP_DTFMT | OP_DTPARSE => { non_num_write[a] = true; non_bool_write[a] = true; } @@ -3612,6 +3619,22 @@ fn compile_function_body( let result = builder.inst_results(call_inst)[0]; builder.def_var(vars[a_idx], result); } + OP_DTFMT => { + let bv = builder.use_var(vars[b_idx]); + let cv = builder.use_var(vars[c_idx]); + let fref = get_func_ref(&mut builder, module, helpers.dtfmt); + let call_inst = builder.ins().call(fref, &[bv, cv]); + let result = builder.inst_results(call_inst)[0]; + builder.def_var(vars[a_idx], result); + } + OP_DTPARSE => { + let bv = builder.use_var(vars[b_idx]); + let cv = builder.use_var(vars[c_idx]); + let fref = get_func_ref(&mut builder, module, helpers.dtparse); + let call_inst = builder.ins().call(fref, &[bv, cv]); + let result = builder.inst_results(call_inst)[0]; + builder.def_var(vars[a_idx], result); + } // ── Type predicates (1-arg → 1 return) ── OP_ISNUM => { let bv = builder.use_var(vars[b_idx]); diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 690b2c47..46fa6bde 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -206,6 +206,8 @@ pub(crate) const OP_ENUMERATE: u8 = 139; // R[A] = enumerate(R[B]) (list of [i, pub(crate) const OP_WINDOW: u8 = 146; // R[A] = window(R[B] (n), R[C] (list)) → list of n-sized sublists pub(crate) const OP_TAKE: u8 = 113; // R[A] = take(R[B], R[C]) (first B elements of C; B=n_reg, C=list_reg) pub(crate) const OP_DROP: u8 = 114; // R[A] = drop(R[B], R[C]) (skip first B elements of C) +pub(crate) const OP_DTFMT: u8 = 131; // R[A] = dtfmt(R[B] epoch, R[C] fmt) → t +pub(crate) const OP_DTPARSE: u8 = 132; // R[A] = dtparse(R[B] text, R[C] fmt) → R n t // ABC mode — text formatting pub(crate) const OP_FMT2: u8 = 104; // R[A] = fmt2(R[B], R[C]) (format number to N decimal places → t) @@ -1990,6 +1992,29 @@ impl RegCompiler { self.reg_is_num[ra as usize] = true; return ra; } + (Builtin::Dtfmt, 2) => { + let rb = self.compile_expr(&args[0]); + let rc = self.compile_expr(&args[1]); + let ra = self.alloc_reg(); + self.emit_abc(OP_DTFMT, ra, rb, rc); + return ra; + } + (Builtin::Dtparse, 2) => { + let rb = self.compile_expr(&args[0]); + let rc = self.compile_expr(&args[1]); + let ra = self.alloc_reg(); + self.emit_abc(OP_DTPARSE, ra, rb, rc); + if *unwrap { + let check_reg = self.alloc_reg(); + self.emit_abc(OP_ISOK, check_reg, ra, 0); + let skip_ret = self.emit_jmpt(check_reg); + self.emit_abx(OP_RET, ra, 0); + self.current.patch_jump(skip_ret); + self.emit_abc(OP_UNWRAP, ra, ra, 0); + self.next_reg = ra + 1; + } + return ra; + } (Builtin::Rnd, 2) => { let rb = self.compile_expr(&args[0]); let rc = self.compile_expr(&args[1]); @@ -2800,7 +2825,7 @@ fn chunk_is_all_numeric(chunk: &Chunk) -> bool { | OP_WR | OP_WRL | OP_MAPNEW | OP_MGET | OP_MSET | OP_MKEYS | OP_MVALS | OP_HD | OP_AT | OP_LST | OP_TL | OP_FMT2 | OP_RGXSUB | OP_ZIP | OP_ENUMERATE | OP_WINDOW | OP_FFT | OP_IFFT | OP_RANGE | OP_CHUNKS | OP_CUMSUM | OP_SETUNION | OP_SETINTER - | OP_SETDIFF | OP_TRANSPOSE | OP_MATMUL | OP_INV | OP_SOLVE => { + | OP_SETDIFF | OP_TRANSPOSE | OP_MATMUL | OP_INV | OP_SOLVE | OP_DTFMT | OP_DTPARSE => { return false; } _ => {} @@ -5847,6 +5872,82 @@ impl<'a> VM<'a> { .as_secs_f64(); reg_set!(a, NanVal::number(ts)); } + OP_DTFMT => { + let a = ((inst >> 16) & 0xFF) as usize + base; + let b = ((inst >> 8) & 0xFF) as usize + base; + let c = (inst & 0xFF) as usize + base; + let vb = reg!(b); + let vc = reg!(c); + if !vb.is_number() { + vm_err!(VmError::Type("dtfmt requires a number (epoch)")); + } + if !vc.is_string() { + vm_err!(VmError::Type("dtfmt requires a string format")); + } + let epoch_f = vb.as_number(); + // SAFETY: is_string() confirmed heap-tagged string with live RC. + let fmt_str: String = unsafe { + match vc.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + let result = if !epoch_f.is_finite() { + NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: epoch is not finite ({epoch_f})" + ))) + } else if epoch_f < i64::MIN as f64 || epoch_f > i64::MAX as f64 { + NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: epoch out of range ({epoch_f})" + ))) + } else { + let secs = epoch_f as i64; + match chrono::DateTime::::from_timestamp(secs, 0) { + Some(dt) => NanVal::heap_ok(NanVal::heap_string( + dt.format(&fmt_str).to_string(), + )), + None => NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: timestamp out of range ({secs})" + ))), + } + }; + reg_set!(a, result); + } + OP_DTPARSE => { + let a = ((inst >> 16) & 0xFF) as usize + base; + let b = ((inst >> 8) & 0xFF) as usize + base; + let c = (inst & 0xFF) as usize + base; + let vb = reg!(b); + let vc = reg!(c); + if !vb.is_string() || !vc.is_string() { + vm_err!(VmError::Type("dtparse requires two strings")); + } + // SAFETY: is_string() confirmed heap-tagged strings with live RC. + let text: String = unsafe { + match vb.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + let fmt_str: String = unsafe { + match vc.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + let parsed = chrono::NaiveDateTime::parse_from_str(&text, &fmt_str) + .map(|ndt| ndt.and_utc().timestamp() as f64) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(&text, &fmt_str).map(|nd| { + nd.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as f64 + }) + }); + let result = match parsed { + Ok(n) => NanVal::heap_ok(NanVal::number(n)), + Err(e) => NanVal::heap_err(NanVal::heap_string(format!("dtparse: {e}"))), + }; + reg_set!(a, result); + } OP_ENV => { let a = ((inst >> 16) & 0xFF) as usize + base; let b = ((inst >> 8) & 0xFF) as usize + base; @@ -10163,6 +10264,77 @@ pub(crate) extern "C" fn jit_rdjl(a: u64) -> u64 { } } +#[cfg(feature = "cranelift")] +#[unsafe(no_mangle)] +pub(crate) extern "C" fn jit_dtfmt(epoch: u64, fmt: u64) -> u64 { + let ev = NanVal(epoch); + let fv = NanVal(fmt); + if !ev.is_number() || !fv.is_string() { + return TAG_NIL; + } + let e_f = ev.as_number(); + let fmt_str = unsafe { + match fv.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + if !e_f.is_finite() { + return NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: epoch is not finite ({e_f})" + ))) + .0; + } + if e_f < i64::MIN as f64 || e_f > i64::MAX as f64 { + return NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: epoch out of range ({e_f})" + ))) + .0; + } + let e = e_f as i64; + match chrono::DateTime::::from_timestamp(e, 0) { + Some(dt) => NanVal::heap_ok(NanVal::heap_string(dt.format(&fmt_str).to_string())).0, + None => { + NanVal::heap_err(NanVal::heap_string(format!( + "dtfmt: timestamp out of range ({e})" + ))) + .0 + } + } +} + +#[cfg(feature = "cranelift")] +#[unsafe(no_mangle)] +pub(crate) extern "C" fn jit_dtparse(text: u64, fmt: u64) -> u64 { + let tv = NanVal(text); + let fv = NanVal(fmt); + if !tv.is_string() || !fv.is_string() { + return TAG_NIL; + } + let t_str = unsafe { + match tv.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + let f_str = unsafe { + match fv.as_heap_ref() { + HeapObj::Str(s) => s.as_str().to_owned(), + _ => unreachable!(), + } + }; + let parsed = chrono::NaiveDateTime::parse_from_str(&t_str, &f_str) + .map(|ndt| ndt.and_utc().timestamp() as f64) + .or_else(|_| { + chrono::NaiveDate::parse_from_str(&t_str, &f_str) + .map(|nd| nd.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp() as f64) + }); + match parsed { + Ok(n) => NanVal::heap_ok(NanVal::number(n)).0, + Err(e) => NanVal::heap_err(NanVal::heap_string(format!("dtparse: {e}"))).0, + } +} + /// Call a VM function from JIT code. `func_idx` is the chunk index, /// `regs` points to `n_args` u64 values. Returns the result as u64. #[cfg(feature = "cranelift")] diff --git a/tests/regression_datetime.rs b/tests/regression_datetime.rs new file mode 100644 index 00000000..7b5d79be --- /dev/null +++ b/tests/regression_datetime.rs @@ -0,0 +1,110 @@ +// Cross-engine smoke tests for the datetime builtins `dtfmt` and `dtparse`. +// +// `dtfmt epoch:n fmt:t > R t t` formats a unix epoch as text via strftime +// (UTC). It returns a Result because the epoch may be non-finite or out of +// range for chrono. +// `dtparse text:t fmt:t > R n t` parses text to an epoch, returning a Result +// because the input may fail to parse. + +use std::process::Command; + +fn ilo() -> Command { + Command::new(env!("CARGO_BIN_EXE_ilo")) +} + +#[cfg(feature = "cranelift")] +const ENGINES: &[&str] = &["--run-tree", "--run-vm", "--run-cranelift"]; +#[cfg(not(feature = "cranelift"))] +const ENGINES: &[&str] = &["--run-tree", "--run-vm"]; + +fn run(engine: &str, src: &str, args: &[&str]) -> (bool, String, String) { + let mut cmd = ilo(); + cmd.arg(src).arg(engine); + for a in args { + cmd.arg(a); + } + let out = cmd.output().expect("failed to run ilo"); + ( + out.status.success(), + String::from_utf8_lossy(&out.stdout).trim().to_string(), + String::from_utf8_lossy(&out.stderr).to_string(), + ) +} + +fn check_text(src: &str, args: &[&str], expected: &str) { + for engine in ENGINES { + let (ok, stdout, stderr) = run(engine, src, args); + assert!(ok, "{engine}: ilo failed for `{src}`: {stderr}"); + // Output may be `~` when wrapped in Ok; we look for substring match. + assert!( + stdout.contains(expected), + "{engine}: src=`{src}` args={args:?} expected `{expected}` in output, got `{stdout}`" + ); + } +} + +#[test] +fn dtfmt_epoch_zero() { + // dtfmt now returns R t t; use `!` to auto-unwrap, then ~ to re-wrap so + // the enclosing R-returning fn matches. + check_text( + "f e:n>R t t;v=dtfmt! e \"%Y-%m-%d\";~v", + &["f", "0"], + "1970-01-01", + ); +} + +#[test] +fn dtfmt_jan_2025() { + check_text( + "f e:n>R t t;v=dtfmt! e \"%Y-%m-%d\";~v", + &["f", "1735689600"], + "2025-01-01", + ); +} + +#[test] +fn dtparse_jan_2025_auto_unwrap() { + // dtparse returns R n t; ! auto-unwraps on Ok and propagates Err. + // Enclosing fn must return R for `!` to be legal; pin the Ok path with + // `~v` so the output contains the parsed epoch. + let src = r#"f s:t>R n t;v=dtparse! s "%Y-%m-%d";~v"#; + for engine in ENGINES { + let (ok, stdout, stderr) = run(engine, src, &["f", "2025-01-01"]); + assert!(ok, "{engine}: ilo failed: {stderr}"); + assert!( + stdout.contains("1735689600"), + "{engine}: expected epoch 1735689600 in output, got `{stdout}`" + ); + } +} + +#[test] +fn dtparse_round_trip() { + // Parse a date back to epoch, then format it again, must be lossless. + // We wrap in Ok at the end since `!` requires R-returning enclosing fn. + let src = r#"f s:t>R t t;e=dtparse! s "%Y-%m-%d";d=dtfmt! e "%Y-%m-%d";~d"#; + for engine in ENGINES { + let (ok, stdout, stderr) = run(engine, src, &["f", "2025-01-01"]); + assert!(ok, "{engine}: ilo failed: {stderr}"); + assert!( + stdout.contains("2025-01-01"), + "{engine}: expected round-trip 2025-01-01, got `{stdout}`" + ); + } +} + +#[test] +fn dtparse_invalid_input_produces_err() { + // Err path: `!` short-circuits and returns the Err to the caller. The + // surfaced error message includes the `dtparse:` prefix from our impl. + let src = r#"f s:t>R n t;v=dtparse! s "%Y-%m-%d";~v"#; + for engine in ENGINES { + let (ok, stdout, stderr) = run(engine, src, &["f", "not-a-date"]); + assert!(ok, "{engine}: ilo failed: {stderr}"); + assert!( + stdout.contains("dtparse"), + "{engine}: expected dtparse error message, got `{stdout}`" + ); + } +}