Skip to content

Commit 9c778c0

Browse files
committed
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 ```
1 parent 44336e5 commit 9c778c0

13 files changed

+84
-66
lines changed

book/src/SUMMARY.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
- [Operations and precedence](./operations.md)
2929
- [Constants](./constant-definitions.md)
3030
- [Unit conversions](./unit-conversions.md)
31-
- [Other conversions](./conversion-functions.md)
31+
- [Conversions functions](./conversion-functions.md)
3232
- [Function definitions](./function-definitions.md)
3333
- [Conditionals](./conditionals.md)
3434
- [Date and time](./date-and-time.md)

book/src/conversion-functions.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Other conversions
1+
# Conversion functions
22

33
The conversion operator `->` (or `to`) can not just be used for [unit conversions](./unit-conversions.md), but also for other types of conversions.
44
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`.

book/src/date-and-time.md

+6-3
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ now() - 1 million seconds
1616
parse_datetime("2024-11-01 12:30:00") - now() -> days
1717
1818
# What time is it in Nepal right now?
19-
now() -> "Asia/Kathmandu" # use tab completion to find time zone names
19+
now() -> tz("Asia/Kathmandu") # use tab completion to find time zone names
2020
2121
# What is the local time when it is 2024-11-01 12:30:00 in Australia?
22-
parse_datetime("2024-11-01 12:30:00 Australia/Sydney") -> "local"
22+
parse_datetime("2024-11-01 12:30:00 Australia/Sydney") -> local
2323
2424
# What is the current UNIX timestamp?
2525
now() -> unixtime
@@ -40,7 +40,7 @@ The following operations are supported for `DateTime` objects:
4040
| `DateTime` | `-` | `DateTime` | Duration between the two dates as a `Time`. In `seconds`, by default. Use normal conversion for other time units. |
4141
| `DateTime` | `+` | `Time` | New `DateTime` by adding the duration to the date |
4242
| `DateTime` | `-` | `Time` | New `DateTime` by subtracting the duration from the date |
43-
| `DateTime` | `->` | `String` | Converts the datetime to the specified time zone. Note that you can use tab-completion for time zone names. |
43+
| `DateTime` | `->` | `tz("…")` | Converts the datetime to the specified time zone. Note that you can use tab-completion for time zone names. |
4444

4545
<div class="warning">
4646

@@ -60,6 +60,9 @@ The following functions are available for date and time handling:
6060
- `now() -> DateTime`: Returns the current date and time.
6161
- `parse_datetime(input: String) -> DateTime`: Parses a string into a `DateTime` object.
6262
- `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.
63+
- `tz(tz: String) -> Fn[(DateTime) -> DateTime]`: Returns a timezone conversion function, typically used with the conversion operator (`datetime -> tz("Europe/Berlin")`)
64+
- `local(dt: DateTime) -> DateTime`: Timezone conversion function targeting the users local timezone (`datetime -> local`)
65+
- `get_local_timezone() -> String`: Returns the users local timezone
6366
- `unixtime(dt: DateTime) -> Scalar`: Converts a `DateTime` to a UNIX timestamp.
6467
- `from_unixtime(ut: Scalar) -> DateTime`: Converts a UNIX timestamp to a `DateTime` object.
6568
- `human(duration: Time) -> String`: Converts a `Time` to a human-readable string in days, hours, minutes and seconds

book/src/list-functions.md

+3
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ fn sphere_volume<L>(radius: L) -> L^3
105105
fn now() -> DateTime
106106
fn parse_datetime(input: String) -> DateTime
107107
fn format_datetime(format: String, dt: DateTime) -> String
108+
fn tz(tz: String) -> Fn[(DateTime) -> DateTime]
109+
fn local(dt: DateTime) -> DateTime
110+
fn get_local_timezone() -> String
108111
fn from_unixtime(t: Scalar) -> DateTime
109112
fn unixtime(dt: DateTime) -> Scalar
110113
fn human(t: Time) -> String

numbat-cli/src/completer.rs

+8-13
Original file line numberDiff line numberDiff line change
@@ -74,20 +74,15 @@ impl Completer for NumbatCompleter {
7474
));
7575
}
7676

77-
// does it look like we're tab-completing a timezone (via the conversion operator)?
78-
let complete_tz = line
79-
.find("->")
80-
.or_else(|| line.find('→'))
81-
.or_else(|| line.find('➞'))
82-
.or_else(|| line.find(" to "))
83-
.and_then(|convert_pos| {
84-
if let Some(quote_pos) = line.rfind('"') {
85-
if quote_pos > convert_pos && pos > quote_pos {
86-
return Some(quote_pos + 1);
87-
}
77+
// does it look like we're tab-completing a timezone?
78+
let complete_tz = line.find("tz(").and_then(|convert_pos| {
79+
if let Some(quote_pos) = line.rfind('"') {
80+
if quote_pos > convert_pos && pos > quote_pos {
81+
return Some(quote_pos + 1);
8882
}
89-
None
90-
});
83+
}
84+
None
85+
});
9186
if let Some(pos_word) = complete_tz {
9287
let word_part = &line[pos_word..];
9388
let matches = self

numbat-cli/src/main.rs

+1-5
Original file line numberDiff line numberDiff line change
@@ -262,11 +262,7 @@ impl Cli {
262262
completer: NumbatCompleter {
263263
context: self.context.clone(),
264264
modules: self.context.lock().unwrap().list_modules().collect(),
265-
all_timezones: {
266-
let mut all_tz: Vec<_> = chrono_tz::TZ_VARIANTS.map(|v| v.name()).into();
267-
all_tz.push("local");
268-
all_tz
269-
},
265+
all_timezones: chrono_tz::TZ_VARIANTS.map(|v| v.name()).into(),
270266
},
271267
highlighter: NumbatHighlighter {
272268
context: self.context.clone(),

numbat/modules/datetime/functions.nbt

+3
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,8 @@ use units::si
33
fn now() -> DateTime
44
fn parse_datetime(input: String) -> DateTime
55
fn format_datetime(format: String, input: DateTime) -> String
6+
fn get_local_timezone() -> String
7+
fn tz(tz: String) -> Fn[(DateTime) -> DateTime]
8+
let local: Fn[(DateTime) -> DateTime] = tz(get_local_timezone())
69
fn unixtime(input: DateTime) -> Scalar
710
fn from_unixtime(input: Scalar) -> DateTime

numbat/src/bytecode_interpreter.rs

-1
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ impl BytecodeInterpreter {
144144
match operator {
145145
BinaryOperator::Add => Op::AddToDateTime,
146146
BinaryOperator::Sub => Op::SubFromDateTime,
147-
BinaryOperator::ConvertTo => Op::ConvertDateTime,
148147
_ => unreachable!("{operator:?} is not valid with a DateTime"), // should be unreachable, because the typechecker will error first
149148
}
150149
};

numbat/src/ffi.rs

+39-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use chrono::Offset;
77
use crate::currency::ExchangeRatesCache;
88
use crate::interpreter::RuntimeError;
99
use crate::pretty_print::PrettyPrint;
10-
use crate::value::Value;
10+
use crate::value::{FunctionReference, Value};
1111
use crate::vm::ExecutionContext;
1212
use crate::{ast::ProcedureKind, quantity::Quantity};
1313

@@ -375,6 +375,24 @@ pub(crate) fn functions() -> &'static HashMap<String, ForeignFunction> {
375375
},
376376
);
377377

378+
m.insert(
379+
"get_local_timezone".to_string(),
380+
ForeignFunction {
381+
name: "get_local_timezone".into(),
382+
arity: 0..=0,
383+
callable: Callable::Function(Box::new(get_local_timezone)),
384+
},
385+
);
386+
387+
m.insert(
388+
"tz".to_string(),
389+
ForeignFunction {
390+
name: "tz".into(),
391+
arity: 1..=1,
392+
callable: Callable::Function(Box::new(tz)),
393+
},
394+
);
395+
378396
m.insert(
379397
"unixtime".to_string(),
380398
ForeignFunction {
@@ -848,6 +866,26 @@ fn format_datetime(args: &[Value]) -> Result<Value> {
848866
Ok(Value::String(output))
849867
}
850868

869+
fn get_local_timezone(args: &[Value]) -> Result<Value> {
870+
assert!(args.len() == 0);
871+
872+
let local_tz = crate::datetime::get_local_timezone()
873+
.unwrap_or(chrono_tz::Tz::UTC)
874+
.to_string();
875+
876+
Ok(Value::String(local_tz))
877+
}
878+
879+
fn tz(args: &[Value]) -> Result<Value> {
880+
assert!(args.len() == 1);
881+
882+
let tz = args[0].unsafe_as_string();
883+
884+
Ok(Value::FunctionReference(FunctionReference::TzConversion(
885+
tz.into(),
886+
)))
887+
}
888+
851889
fn unixtime(args: &[Value]) -> Result<Value> {
852890
assert!(args.len() == 1);
853891

numbat/src/typechecker.rs

+1-11
Original file line numberDiff line numberDiff line change
@@ -760,24 +760,14 @@ impl TypeChecker {
760760
} else if lhs_checked.get_type() == Type::DateTime {
761761
// DateTime types need special handling here, since they're not scalars with dimensions,
762762
// yet some select binary operators can be applied to them
763-
// TODO how to better handle all the operations we want to support with date
764763

765764
let rhs_is_time = dtype(&rhs_checked)
766765
.ok()
767766
.map(|t| t.is_time_dimension())
768767
.unwrap_or(false);
769768
let rhs_is_datetime = rhs_checked.get_type() == Type::DateTime;
770769

771-
if *op == BinaryOperator::ConvertTo && rhs_checked.get_type() == Type::String {
772-
// Supports timezone conversion
773-
typed_ast::Expression::BinaryOperatorForDate(
774-
*span_op,
775-
*op,
776-
Box::new(lhs_checked),
777-
Box::new(rhs_checked),
778-
Type::DateTime,
779-
)
780-
} else if *op == BinaryOperator::Sub && rhs_is_datetime {
770+
if *op == BinaryOperator::Sub && rhs_is_datetime {
781771
let time = self
782772
.registry
783773
.get_base_representation_for_name("Time")

numbat/src/typed_ast.rs

+1-1
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ pub enum Expression {
168168
BinaryOperator,
169169
/// LHS must evaluate to a DateTime
170170
Box<Expression>,
171-
/// RHS can evaluate to a DateTime, a quantity of type Time, or a String (for timezone conversions)
171+
/// RHS can evaluate to a DateTime or a quantity of type Time
172172
Box<Expression>,
173173
Type,
174174
),

numbat/src/value.rs

+7-2
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ use crate::{pretty_print::PrettyPrint, quantity::Quantity};
44
pub enum FunctionReference {
55
Foreign(String),
66
Normal(String),
7+
// TODO: We can get rid of this variant once we implement closures:
8+
TzConversion(String),
79
}
810

911
impl std::fmt::Display for FunctionReference {
1012
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1113
match self {
12-
FunctionReference::Foreign(name) => write!(f, "<builtin function: {}>", name),
13-
FunctionReference::Normal(name) => write!(f, "<function: {}>", name),
14+
FunctionReference::Foreign(name) => write!(f, "<builtin function: {name}>"),
15+
FunctionReference::Normal(name) => write!(f, "<function: {name}>"),
16+
FunctionReference::TzConversion(tz) => {
17+
write!(f, "<builtin timezone conversion function: {tz}>")
18+
}
1419
}
1520
}
1621
}

numbat/src/vm.rs

+13-27
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ pub enum Op {
7878
SubFromDateTime,
7979
/// Computes the difference between two DateTimes
8080
DiffDateTime,
81-
/// Converts a DateTime value to another timezone
82-
ConvertDateTime,
8381

8482
/// Move IP forward by the given offset argument if the popped-of value on
8583
/// top of the stack is false.
@@ -130,7 +128,6 @@ impl Op {
130128
| Op::Subtract
131129
| Op::SubFromDateTime
132130
| Op::DiffDateTime
133-
| Op::ConvertDateTime
134131
| Op::Multiply
135132
| Op::Divide
136133
| Op::Power
@@ -165,7 +162,6 @@ impl Op {
165162
Op::Subtract => "Subtract",
166163
Op::SubFromDateTime => "SubDateTime",
167164
Op::DiffDateTime => "DiffDateTime",
168-
Op::ConvertDateTime => "ConvertDateTime",
169165
Op::Multiply => "Multiply",
170166
Op::Divide => "Divide",
171167
Op::Power => "Power",
@@ -544,14 +540,6 @@ impl Vm {
544540
}
545541
}
546542

547-
#[track_caller]
548-
fn pop_string(&mut self) -> String {
549-
match self.pop() {
550-
Value::String(s) => s,
551-
_ => panic!("Expected string to be on the top of the stack"),
552-
}
553-
}
554-
555543
#[track_caller]
556544
fn pop(&mut self) -> Value {
557545
self.stack.pop().expect("stack should not be empty")
@@ -705,21 +693,6 @@ impl Vm {
705693

706694
self.push(ret);
707695
}
708-
Op::ConvertDateTime => {
709-
let rhs = self.pop_string();
710-
let lhs = self.pop_datetime();
711-
712-
let offset = if rhs == "local" {
713-
crate::datetime::local_offset_for_datetime(&lhs)
714-
} else {
715-
let tz: chrono_tz::Tz = rhs
716-
.parse()
717-
.map_err(|_| RuntimeError::UnknownTimezone(rhs))?;
718-
lhs.with_timezone(&tz).offset().fix()
719-
};
720-
721-
self.push(Value::DateTime(lhs, offset));
722-
}
723696
op @ (Op::LessThan | Op::GreaterThan | Op::LessOrEqual | Op::GreatorOrEqual) => {
724697
let rhs = self.pop_quantity();
725698
let lhs = self.pop_quantity();
@@ -870,6 +843,19 @@ impl Vm {
870843
Callable::Procedure(..) => unreachable!("Foreign procedures can not be targeted by a function reference"),
871844
}
872845
}
846+
FunctionReference::TzConversion(tz_name) => {
847+
// TODO: implement this using a closure, once we have that in the language
848+
849+
let dt = self.pop_datetime();
850+
851+
let tz: chrono_tz::Tz = tz_name
852+
.parse()
853+
.map_err(|_| RuntimeError::UnknownTimezone(tz_name.into()))?;
854+
855+
let offset = dt.with_timezone(&tz).offset().fix();
856+
857+
self.push(Value::DateTime(dt, offset));
858+
}
873859
}
874860
}
875861
Op::PrintString => {

0 commit comments

Comments
 (0)