From 0d5e18e991db97d0943d2126a8aeedfdb0d84db5 Mon Sep 17 00:00:00 2001 From: David Peter Date: Mon, 12 Feb 2024 09:28:19 +0100 Subject: [PATCH] Embed timezone conversion into conversion-function framework This is a new implementation of the timezone conversion functionality on top of the conversion-function framework. Previously, we could convert `DateTime`s using `dt -> "Europe/Berlin"`, i.e. with a string on the right hand side. This required special handling in the type checker, the compiler and the Vm. With this change, we use conversion functions instead. This requires users to type `dt -> tz("Europe/Berlin")`, but is conceptually cleaner and does not require special handling in the compiler. Well it does require special handling in the FFI module for now, but only because we don't have anonymous functions / closures yet. I think this is still a benefitial change overall, as it makes the conversion operator conceptually simpler. It can either have a unit on the right hand side, or a conversion function. We also introduce a new `local = tz(get_local_timezone())` function which is a bit simpler to type (`dt -> local`) compared to the special "local" string before. Like before, users can still set aliases for timezones. For example: ``` let Florida = tz("US/Eastern") now() -> Florida ``` --- book/src/SUMMARY.md | 2 +- book/src/conversion-functions.md | 2 +- book/src/date-and-time.md | 9 ++++-- book/src/list-functions.md | 3 ++ numbat-cli/src/completer.rs | 21 ++++++-------- numbat-cli/src/main.rs | 6 +--- numbat/modules/datetime/functions.nbt | 3 ++ numbat/src/bytecode_interpreter.rs | 1 - numbat/src/ffi.rs | 40 ++++++++++++++++++++++++++- numbat/src/typechecker.rs | 12 +------- numbat/src/typed_ast.rs | 2 +- numbat/src/value.rs | 9 ++++-- numbat/src/vm.rs | 40 +++++++++------------------ 13 files changed, 84 insertions(+), 66 deletions(-) diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 9ca433b7..cf4ef127 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -28,7 +28,7 @@ - [Operations and precedence](./operations.md) - [Constants](./constant-definitions.md) - [Unit conversions](./unit-conversions.md) - - [Other conversions](./conversion-functions.md) + - [Conversions functions](./conversion-functions.md) - [Function definitions](./function-definitions.md) - [Conditionals](./conditionals.md) - [Date and time](./date-and-time.md) diff --git a/book/src/conversion-functions.md b/book/src/conversion-functions.md index afa8ec5e..88275b6c 100644 --- a/book/src/conversion-functions.md +++ b/book/src/conversion-functions.md @@ -1,4 +1,4 @@ -# Other conversions +# Conversion functions The conversion operator `->` (or `to`) can not just be used for [unit conversions](./unit-conversions.md), but also for other types of conversions. The way this is set up in Numbat is that you can call `x -> f` for any function `f` that takes a single argument of the same type as `x`. diff --git a/book/src/date-and-time.md b/book/src/date-and-time.md index 8ca0365a..777a3549 100644 --- a/book/src/date-and-time.md +++ b/book/src/date-and-time.md @@ -16,10 +16,10 @@ now() - 1 million seconds parse_datetime("2024-11-01 12:30:00") - now() -> days # What time is it in Nepal right now? -now() -> "Asia/Kathmandu" # use tab completion to find time zone names +now() -> tz("Asia/Kathmandu") # use tab completion to find time zone names # What is the local time when it is 2024-11-01 12:30:00 in Australia? -parse_datetime("2024-11-01 12:30:00 Australia/Sydney") -> "local" +parse_datetime("2024-11-01 12:30:00 Australia/Sydney") -> local # What is the current UNIX timestamp? now() -> unixtime @@ -40,7 +40,7 @@ The following operations are supported for `DateTime` objects: | `DateTime` | `-` | `DateTime` | Duration between the two dates as a `Time`. In `seconds`, by default. Use normal conversion for other time units. | | `DateTime` | `+` | `Time` | New `DateTime` by adding the duration to the date | | `DateTime` | `-` | `Time` | New `DateTime` by subtracting the duration from the date | -| `DateTime` | `->` | `String` | Converts the datetime to the specified time zone. Note that you can use tab-completion for time zone names. | +| `DateTime` | `->` | `tz("…")` | Converts the datetime to the specified time zone. Note that you can use tab-completion for time zone names. |
@@ -60,6 +60,9 @@ The following functions are available for date and time handling: - `now() -> DateTime`: Returns the current date and time. - `parse_datetime(input: String) -> DateTime`: Parses a string into a `DateTime` object. - `format_datetime(format: String, dt: DateTime) -> String`: Formats a `DateTime` object as a string. See [this page](https://docs.rs/chrono/latest/chrono/format/strftime/index.html#specifiers) for possible format specifiers. +- `tz(tz: String) -> Fn[(DateTime) -> DateTime]`: Returns a timezone conversion function, typically used with the conversion operator (`datetime -> tz("Europe/Berlin")`) +- `local(dt: DateTime) -> DateTime`: Timezone conversion function targeting the users local timezone (`datetime -> local`) +- `get_local_timezone() -> String`: Returns the users local timezone - `unixtime(dt: DateTime) -> Scalar`: Converts a `DateTime` to a UNIX timestamp. - `from_unixtime(ut: Scalar) -> DateTime`: Converts a UNIX timestamp to a `DateTime` object. - `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds diff --git a/book/src/list-functions.md b/book/src/list-functions.md index b6703365..ce80f024 100644 --- a/book/src/list-functions.md +++ b/book/src/list-functions.md @@ -105,6 +105,9 @@ fn sphere_volume(radius: L) -> L^3 fn now() -> DateTime fn parse_datetime(input: String) -> DateTime fn format_datetime(format: String, dt: DateTime) -> String +fn tz(tz: String) -> Fn[(DateTime) -> DateTime] +fn local(dt: DateTime) -> DateTime +fn get_local_timezone() -> String fn from_unixtime(t: Scalar) -> DateTime fn unixtime(dt: DateTime) -> Scalar fn human(t: Time) -> String diff --git a/numbat-cli/src/completer.rs b/numbat-cli/src/completer.rs index 59b73857..2d9b8fd8 100644 --- a/numbat-cli/src/completer.rs +++ b/numbat-cli/src/completer.rs @@ -74,20 +74,15 @@ impl Completer for NumbatCompleter { )); } - // does it look like we're tab-completing a timezone (via the conversion operator)? - let complete_tz = line - .find("->") - .or_else(|| line.find('→')) - .or_else(|| line.find('➞')) - .or_else(|| line.find(" to ")) - .and_then(|convert_pos| { - if let Some(quote_pos) = line.rfind('"') { - if quote_pos > convert_pos && pos > quote_pos { - return Some(quote_pos + 1); - } + // does it look like we're tab-completing a timezone? + let complete_tz = line.find("tz(").and_then(|convert_pos| { + if let Some(quote_pos) = line.rfind('"') { + if quote_pos > convert_pos && pos > quote_pos { + return Some(quote_pos + 1); } - None - }); + } + None + }); if let Some(pos_word) = complete_tz { let word_part = &line[pos_word..]; let matches = self diff --git a/numbat-cli/src/main.rs b/numbat-cli/src/main.rs index 156376d7..a68b8306 100644 --- a/numbat-cli/src/main.rs +++ b/numbat-cli/src/main.rs @@ -262,11 +262,7 @@ impl Cli { completer: NumbatCompleter { context: self.context.clone(), modules: self.context.lock().unwrap().list_modules().collect(), - all_timezones: { - let mut all_tz: Vec<_> = chrono_tz::TZ_VARIANTS.map(|v| v.name()).into(); - all_tz.push("local"); - all_tz - }, + all_timezones: chrono_tz::TZ_VARIANTS.map(|v| v.name()).into(), }, highlighter: NumbatHighlighter { context: self.context.clone(), diff --git a/numbat/modules/datetime/functions.nbt b/numbat/modules/datetime/functions.nbt index 6908f9a4..87352653 100644 --- a/numbat/modules/datetime/functions.nbt +++ b/numbat/modules/datetime/functions.nbt @@ -3,5 +3,8 @@ use units::si fn now() -> DateTime fn parse_datetime(input: String) -> DateTime fn format_datetime(format: String, input: DateTime) -> String +fn get_local_timezone() -> String +fn tz(tz: String) -> Fn[(DateTime) -> DateTime] +let local: Fn[(DateTime) -> DateTime] = tz(get_local_timezone()) fn unixtime(input: DateTime) -> Scalar fn from_unixtime(input: Scalar) -> DateTime diff --git a/numbat/src/bytecode_interpreter.rs b/numbat/src/bytecode_interpreter.rs index 608cf6e5..ce301ee1 100644 --- a/numbat/src/bytecode_interpreter.rs +++ b/numbat/src/bytecode_interpreter.rs @@ -144,7 +144,6 @@ impl BytecodeInterpreter { match operator { BinaryOperator::Add => Op::AddToDateTime, BinaryOperator::Sub => Op::SubFromDateTime, - BinaryOperator::ConvertTo => Op::ConvertDateTime, _ => unreachable!("{operator:?} is not valid with a DateTime"), // should be unreachable, because the typechecker will error first } }; diff --git a/numbat/src/ffi.rs b/numbat/src/ffi.rs index 13b0e75b..cf511020 100644 --- a/numbat/src/ffi.rs +++ b/numbat/src/ffi.rs @@ -7,7 +7,7 @@ use chrono::Offset; use crate::currency::ExchangeRatesCache; use crate::interpreter::RuntimeError; use crate::pretty_print::PrettyPrint; -use crate::value::Value; +use crate::value::{FunctionReference, Value}; use crate::vm::ExecutionContext; use crate::{ast::ProcedureKind, quantity::Quantity}; @@ -375,6 +375,24 @@ pub(crate) fn functions() -> &'static HashMap { }, ); + m.insert( + "get_local_timezone".to_string(), + ForeignFunction { + name: "get_local_timezone".into(), + arity: 0..=0, + callable: Callable::Function(Box::new(get_local_timezone)), + }, + ); + + m.insert( + "tz".to_string(), + ForeignFunction { + name: "tz".into(), + arity: 1..=1, + callable: Callable::Function(Box::new(tz)), + }, + ); + m.insert( "unixtime".to_string(), ForeignFunction { @@ -848,6 +866,26 @@ fn format_datetime(args: &[Value]) -> Result { Ok(Value::String(output)) } +fn get_local_timezone(args: &[Value]) -> Result { + assert!(args.len() == 0); + + let local_tz = crate::datetime::get_local_timezone() + .unwrap_or(chrono_tz::Tz::UTC) + .to_string(); + + Ok(Value::String(local_tz)) +} + +fn tz(args: &[Value]) -> Result { + assert!(args.len() == 1); + + let tz = args[0].unsafe_as_string(); + + Ok(Value::FunctionReference(FunctionReference::TzConversion( + tz.into(), + ))) +} + fn unixtime(args: &[Value]) -> Result { assert!(args.len() == 1); diff --git a/numbat/src/typechecker.rs b/numbat/src/typechecker.rs index 38c3842c..9960c003 100644 --- a/numbat/src/typechecker.rs +++ b/numbat/src/typechecker.rs @@ -760,7 +760,6 @@ impl TypeChecker { } else if lhs_checked.get_type() == Type::DateTime { // DateTime types need special handling here, since they're not scalars with dimensions, // yet some select binary operators can be applied to them - // TODO how to better handle all the operations we want to support with date let rhs_is_time = dtype(&rhs_checked) .ok() @@ -768,16 +767,7 @@ impl TypeChecker { .unwrap_or(false); let rhs_is_datetime = rhs_checked.get_type() == Type::DateTime; - if *op == BinaryOperator::ConvertTo && rhs_checked.get_type() == Type::String { - // Supports timezone conversion - typed_ast::Expression::BinaryOperatorForDate( - *span_op, - *op, - Box::new(lhs_checked), - Box::new(rhs_checked), - Type::DateTime, - ) - } else if *op == BinaryOperator::Sub && rhs_is_datetime { + if *op == BinaryOperator::Sub && rhs_is_datetime { let time = self .registry .get_base_representation_for_name("Time") diff --git a/numbat/src/typed_ast.rs b/numbat/src/typed_ast.rs index 556026c6..411613d3 100644 --- a/numbat/src/typed_ast.rs +++ b/numbat/src/typed_ast.rs @@ -168,7 +168,7 @@ pub enum Expression { BinaryOperator, /// LHS must evaluate to a DateTime Box, - /// RHS can evaluate to a DateTime, a quantity of type Time, or a String (for timezone conversions) + /// RHS can evaluate to a DateTime or a quantity of type Time Box, Type, ), diff --git a/numbat/src/value.rs b/numbat/src/value.rs index 8caaafdd..4c7da42e 100644 --- a/numbat/src/value.rs +++ b/numbat/src/value.rs @@ -4,13 +4,18 @@ use crate::{pretty_print::PrettyPrint, quantity::Quantity}; pub enum FunctionReference { Foreign(String), Normal(String), + // TODO: We can get rid of this variant once we implement closures: + TzConversion(String), } impl std::fmt::Display for FunctionReference { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - FunctionReference::Foreign(name) => write!(f, "", name), - FunctionReference::Normal(name) => write!(f, "", name), + FunctionReference::Foreign(name) => write!(f, ""), + FunctionReference::Normal(name) => write!(f, ""), + FunctionReference::TzConversion(tz) => { + write!(f, "") + } } } } diff --git a/numbat/src/vm.rs b/numbat/src/vm.rs index afe70c69..071af8cd 100644 --- a/numbat/src/vm.rs +++ b/numbat/src/vm.rs @@ -78,8 +78,6 @@ pub enum Op { SubFromDateTime, /// Computes the difference between two DateTimes DiffDateTime, - /// Converts a DateTime value to another timezone - ConvertDateTime, /// Move IP forward by the given offset argument if the popped-of value on /// top of the stack is false. @@ -130,7 +128,6 @@ impl Op { | Op::Subtract | Op::SubFromDateTime | Op::DiffDateTime - | Op::ConvertDateTime | Op::Multiply | Op::Divide | Op::Power @@ -165,7 +162,6 @@ impl Op { Op::Subtract => "Subtract", Op::SubFromDateTime => "SubDateTime", Op::DiffDateTime => "DiffDateTime", - Op::ConvertDateTime => "ConvertDateTime", Op::Multiply => "Multiply", Op::Divide => "Divide", Op::Power => "Power", @@ -544,14 +540,6 @@ impl Vm { } } - #[track_caller] - fn pop_string(&mut self) -> String { - match self.pop() { - Value::String(s) => s, - _ => panic!("Expected string to be on the top of the stack"), - } - } - #[track_caller] fn pop(&mut self) -> Value { self.stack.pop().expect("stack should not be empty") @@ -705,21 +693,6 @@ impl Vm { self.push(ret); } - Op::ConvertDateTime => { - let rhs = self.pop_string(); - let lhs = self.pop_datetime(); - - let offset = if rhs == "local" { - crate::datetime::local_offset_for_datetime(&lhs) - } else { - let tz: chrono_tz::Tz = rhs - .parse() - .map_err(|_| RuntimeError::UnknownTimezone(rhs))?; - lhs.with_timezone(&tz).offset().fix() - }; - - self.push(Value::DateTime(lhs, offset)); - } op @ (Op::LessThan | Op::GreaterThan | Op::LessOrEqual | Op::GreatorOrEqual) => { let rhs = self.pop_quantity(); let lhs = self.pop_quantity(); @@ -870,6 +843,19 @@ impl Vm { Callable::Procedure(..) => unreachable!("Foreign procedures can not be targeted by a function reference"), } } + FunctionReference::TzConversion(tz_name) => { + // TODO: implement this using a closure, once we have that in the language + + let dt = self.pop_datetime(); + + let tz: chrono_tz::Tz = tz_name + .parse() + .map_err(|_| RuntimeError::UnknownTimezone(tz_name.into()))?; + + let offset = dt.with_timezone(&tz).offset().fix(); + + self.push(Value::DateTime(dt, offset)); + } } } Op::PrintString => {