1use std::cell::Cell;
2use std::collections::HashMap;
3
4use runmat_builtins::{
5 Access, BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
6 BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
7 CharArray, ClassDef, MethodDef, ObjectInstance, PropertyDef, StringArray, Tensor, Value,
8};
9
10use crate::builtins::common::tensor;
11use crate::{
12 build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError, OBJECT_INDEX_MEMBER,
13 OBJECT_INDEX_PAREN, OBJECT_SUBSASGN_METHOD, OBJECT_SUBSREF_METHOD,
14};
15
16const BUILTIN_NAME: &str = "duration";
17const DURATION_CLASS: &str = "duration";
18const DAYS_FIELD: &str = "__days";
19const FORMAT_FIELD: &str = "Format";
20pub(crate) const DEFAULT_DURATION_FORMAT: &str = "hh:mm:ss";
21const SECONDS_PER_DAY: f64 = 86_400.0;
22
23thread_local! {
24 static DURATION_CLASS_REGISTERED: Cell<bool> = const { Cell::new(false) };
25}
26
27const DURATION_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
28 code: "RM.DURATION.INVALID_ARGUMENT",
29 identifier: Some("RunMat:duration:InvalidArgument"),
30 when: "Arguments or option grammar do not match supported duration forms.",
31 message: "duration: invalid argument",
32};
33const DURATION_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
34 code: "RM.DURATION.INVALID_INPUT",
35 identifier: Some("RunMat:duration:InvalidInput"),
36 when: "Input values cannot be converted/broadcast/formatted to a valid duration result.",
37 message: "duration: invalid input",
38};
39const DURATION_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
40 code: "RM.DURATION.INTERNAL",
41 identifier: Some("RunMat:duration:Internal"),
42 when: "Internal duration state or indexing/evaluation failed unexpectedly.",
43 message: "duration: internal operation failed",
44};
45const DURATION_ERRORS: [BuiltinErrorDescriptor; 3] = [
46 DURATION_ERROR_INVALID_ARGUMENT,
47 DURATION_ERROR_INVALID_INPUT,
48 DURATION_ERROR_INTERNAL,
49];
50
51const OUT_DURATION: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
52 name: "t",
53 ty: BuiltinParamType::Any,
54 arity: BuiltinParamArity::Required,
55 default: None,
56 description: "Duration object result.",
57}];
58const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
59 name: "out",
60 ty: BuiltinParamType::Any,
61 arity: BuiltinParamArity::Required,
62 default: None,
63 description: "Method result.",
64}];
65const DURATION_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
66 name: "args",
67 ty: BuiltinParamType::Any,
68 arity: BuiltinParamArity::Variadic,
69 default: None,
70 description: "Duration constructor arguments.",
71}];
72const DURATION_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
73 BuiltinParamDescriptor {
74 name: "lhs",
75 ty: BuiltinParamType::Any,
76 arity: BuiltinParamArity::Required,
77 default: None,
78 description: "Left duration operand.",
79 },
80 BuiltinParamDescriptor {
81 name: "rhs",
82 ty: BuiltinParamType::Any,
83 arity: BuiltinParamArity::Required,
84 default: None,
85 description: "Right duration/datetime operand.",
86 },
87];
88const DURATION_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
89 BuiltinParamDescriptor {
90 name: "obj",
91 ty: BuiltinParamType::Any,
92 arity: BuiltinParamArity::Required,
93 default: None,
94 description: "Duration receiver object.",
95 },
96 BuiltinParamDescriptor {
97 name: "kind",
98 ty: BuiltinParamType::StringScalar,
99 arity: BuiltinParamArity::Required,
100 default: None,
101 description: "Indexing kind token.",
102 },
103 BuiltinParamDescriptor {
104 name: "payload",
105 ty: BuiltinParamType::Any,
106 arity: BuiltinParamArity::Required,
107 default: None,
108 description: "Index/member payload.",
109 },
110];
111const DURATION_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
112 BuiltinParamDescriptor {
113 name: "obj",
114 ty: BuiltinParamType::Any,
115 arity: BuiltinParamArity::Required,
116 default: None,
117 description: "Duration receiver object.",
118 },
119 BuiltinParamDescriptor {
120 name: "kind",
121 ty: BuiltinParamType::StringScalar,
122 arity: BuiltinParamArity::Required,
123 default: None,
124 description: "Indexing kind token.",
125 },
126 BuiltinParamDescriptor {
127 name: "payload",
128 ty: BuiltinParamType::Any,
129 arity: BuiltinParamArity::Required,
130 default: None,
131 description: "Index/member payload.",
132 },
133 BuiltinParamDescriptor {
134 name: "rhs",
135 ty: BuiltinParamType::Any,
136 arity: BuiltinParamArity::Required,
137 default: None,
138 description: "Assigned value.",
139 },
140];
141
142const DURATION_SIGNATURES: [BuiltinSignatureDescriptor; 5] = [
143 BuiltinSignatureDescriptor {
144 label: "t = duration(hours)",
145 inputs: &[BuiltinParamDescriptor {
146 name: "hours",
147 ty: BuiltinParamType::NumericArray,
148 arity: BuiltinParamArity::Required,
149 default: None,
150 description: "Hour component.",
151 }],
152 outputs: &OUT_DURATION,
153 },
154 BuiltinSignatureDescriptor {
155 label: "t = duration(hours, minutes)",
156 inputs: &[
157 BuiltinParamDescriptor {
158 name: "hours",
159 ty: BuiltinParamType::NumericArray,
160 arity: BuiltinParamArity::Required,
161 default: None,
162 description: "Hour component.",
163 },
164 BuiltinParamDescriptor {
165 name: "minutes",
166 ty: BuiltinParamType::NumericArray,
167 arity: BuiltinParamArity::Required,
168 default: None,
169 description: "Minute component.",
170 },
171 ],
172 outputs: &OUT_DURATION,
173 },
174 BuiltinSignatureDescriptor {
175 label: "t = duration(hours, minutes, seconds)",
176 inputs: &[
177 BuiltinParamDescriptor {
178 name: "hours",
179 ty: BuiltinParamType::NumericArray,
180 arity: BuiltinParamArity::Required,
181 default: None,
182 description: "Hour component.",
183 },
184 BuiltinParamDescriptor {
185 name: "minutes",
186 ty: BuiltinParamType::NumericArray,
187 arity: BuiltinParamArity::Required,
188 default: None,
189 description: "Minute component.",
190 },
191 BuiltinParamDescriptor {
192 name: "seconds",
193 ty: BuiltinParamType::NumericArray,
194 arity: BuiltinParamArity::Required,
195 default: None,
196 description: "Second component.",
197 },
198 ],
199 outputs: &OUT_DURATION,
200 },
201 BuiltinSignatureDescriptor {
202 label: "t = duration(___, \"Format\", format)",
203 inputs: &DURATION_ARGS_ONLY,
204 outputs: &OUT_DURATION,
205 },
206 BuiltinSignatureDescriptor {
207 label: "t = duration(___, Name, Value, ...)",
208 inputs: &DURATION_ARGS_ONLY,
209 outputs: &OUT_DURATION,
210 },
211];
212const DURATION_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
213 label: "out = duration.subsref(obj, kind, payload)",
214 inputs: &DURATION_SUBSREF_INPUTS,
215 outputs: &OUT_ANY,
216}];
217const DURATION_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
218 [BuiltinSignatureDescriptor {
219 label: "out = duration.subsasgn(obj, kind, payload, rhs)",
220 inputs: &DURATION_SUBSASGN_INPUTS,
221 outputs: &OUT_ANY,
222 }];
223const DURATION_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
224 label: "out = duration.op(lhs, rhs)",
225 inputs: &DURATION_BINARY_INPUTS,
226 outputs: &OUT_ANY,
227}];
228
229pub const DURATION_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
230 signatures: &DURATION_SIGNATURES,
231 output_mode: BuiltinOutputMode::Fixed,
232 completion_policy: BuiltinCompletionPolicy::Public,
233 errors: &DURATION_ERRORS,
234};
235pub const DURATION_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
236 signatures: &DURATION_SUBSREF_SIGNATURES,
237 output_mode: BuiltinOutputMode::Fixed,
238 completion_policy: BuiltinCompletionPolicy::MethodOnly,
239 errors: &DURATION_ERRORS,
240};
241pub const DURATION_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
242 signatures: &DURATION_SUBSASGN_SIGNATURES,
243 output_mode: BuiltinOutputMode::Fixed,
244 completion_policy: BuiltinCompletionPolicy::MethodOnly,
245 errors: &DURATION_ERRORS,
246};
247pub const DURATION_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
248 signatures: &DURATION_BINARY_SIGNATURES,
249 output_mode: BuiltinOutputMode::Fixed,
250 completion_policy: BuiltinCompletionPolicy::MethodOnly,
251 errors: &DURATION_ERRORS,
252};
253
254fn duration_error(message: impl Into<String>) -> RuntimeError {
255 build_runtime_error(message)
256 .with_builtin(BUILTIN_NAME)
257 .build()
258}
259
260fn ensure_duration_class_registered() {
261 DURATION_CLASS_REGISTERED.with(|registered| {
262 if registered.get() {
263 return;
264 }
265 let mut properties = HashMap::new();
266 properties.insert(
267 FORMAT_FIELD.to_string(),
268 PropertyDef {
269 name: FORMAT_FIELD.to_string(),
270 is_static: false,
271 is_constant: false,
272 is_dependent: false,
273 get_access: Access::Public,
274 set_access: Access::Public,
275 default_value: Some(Value::String(DEFAULT_DURATION_FORMAT.to_string())),
276 },
277 );
278
279 let mut methods = HashMap::new();
280 for name in [
281 OBJECT_SUBSREF_METHOD,
282 OBJECT_SUBSASGN_METHOD,
283 "plus",
284 "minus",
285 "eq",
286 "ne",
287 "lt",
288 "le",
289 "gt",
290 "ge",
291 ] {
292 methods.insert(
293 name.to_string(),
294 MethodDef {
295 name: name.to_string(),
296 is_static: false,
297 is_abstract: false,
298 is_sealed: false,
299 access: Access::Public,
300 function_name: format!("{DURATION_CLASS}.{name}"),
301 implicit_class_argument: None,
302 },
303 );
304 }
305
306 runmat_builtins::register_class(ClassDef {
307 name: DURATION_CLASS.to_string(),
308 parent: None,
309 properties,
310 methods,
311 });
312 registered.set(true);
313 });
314}
315
316pub fn is_duration_object(value: &Value) -> bool {
317 matches!(value, Value::Object(obj) if obj.is_class(DURATION_CLASS))
318}
319
320async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
321 let mut out = Vec::with_capacity(args.len());
322 for arg in args {
323 out.push(
324 gather_if_needed_async(arg)
325 .await
326 .map_err(|err| duration_error(format!("duration: {}", err.message())))?,
327 );
328 }
329 Ok(out)
330}
331
332fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
333 match value {
334 Value::String(text) => Ok(text.clone()),
335 Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
336 Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
337 _ => Err(duration_error(format!(
338 "duration: {context} must be a string scalar or character vector"
339 ))),
340 }
341}
342
343fn parse_trailing_format(args: &[Value]) -> BuiltinResult<(usize, Option<String>)> {
344 let mut positional_end = args.len();
345 let mut format = None;
346
347 while positional_end >= 2 {
348 let name = match scalar_text(&args[positional_end - 2], "option name") {
349 Ok(text) => text,
350 Err(_) => break,
351 };
352 if !name.trim().eq_ignore_ascii_case("format") {
353 break;
354 }
355 format = Some(scalar_text(&args[positional_end - 1], "Format option")?);
356 positional_end -= 2;
357 }
358
359 Ok((positional_end, format))
360}
361
362fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
363 tensor::value_into_tensor_for(context, value)
364 .map_err(|message| duration_error(format!("duration: {message}")))
365}
366
367fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
368 let tensor = tensor_from_numeric(value, context)?;
369 Tensor::new(
370 tensor.data.clone(),
371 tensor::default_shape_for(&tensor.shape, tensor.data.len()),
372 )
373 .map_err(|err| duration_error(format!("duration: {err}")))
374}
375
376fn format_for_object(obj: &ObjectInstance) -> String {
377 match obj.properties.get(FORMAT_FIELD) {
378 Some(Value::String(text)) => text.clone(),
379 Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
380 Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
381 _ => DEFAULT_DURATION_FORMAT.to_string(),
382 }
383}
384
385pub(crate) fn duration_tensor_from_duration_value(value: &Value) -> BuiltinResult<Tensor> {
386 match value {
387 Value::Object(obj) if obj.is_class(DURATION_CLASS) => {
388 match obj.properties.get(DAYS_FIELD) {
389 Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
390 Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
391 .map_err(|err| duration_error(format!("duration: {err}"))),
392 Some(other) => Err(duration_error(format!(
393 "duration: invalid internal day storage {other:?}"
394 ))),
395 None => Err(duration_error("duration: missing internal day storage")),
396 }
397 }
398 _ => Err(duration_error("duration: expected a duration value")),
399 }
400}
401
402pub(crate) fn duration_format_from_value(value: &Value) -> String {
403 match value {
404 Value::Object(obj) if obj.is_class(DURATION_CLASS) => format_for_object(obj),
405 _ => DEFAULT_DURATION_FORMAT.to_string(),
406 }
407}
408
409pub(crate) fn duration_object_from_days_tensor(
410 days: Tensor,
411 format: impl Into<String>,
412) -> BuiltinResult<Value> {
413 ensure_duration_class_registered();
414 let mut object = ObjectInstance::new(DURATION_CLASS.to_string());
415 object
416 .properties
417 .insert(DAYS_FIELD.to_string(), Value::Tensor(days));
418 object
419 .properties
420 .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
421 Ok(Value::Object(object))
422}
423
424fn duration_object_from_days(
425 days: Vec<f64>,
426 shape: Vec<usize>,
427 format: impl Into<String>,
428) -> BuiltinResult<Value> {
429 let tensor =
430 Tensor::new(days, shape).map_err(|err| duration_error(format!("duration: {err}")))?;
431 duration_object_from_days_tensor(tensor, format)
432}
433
434fn broadcast_component_data(
435 arrays: &[Tensor],
436 labels: &[&str],
437) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
438 let mut target_shape = vec![1, 1];
439 let mut target_len = 1usize;
440
441 for array in arrays {
442 let len = array.data.len();
443 if len > 1 {
444 let shape = tensor::default_shape_for(&array.shape, len);
445 if target_len == 1 {
446 target_len = len;
447 target_shape = shape;
448 } else if len != target_len || shape != target_shape {
449 return Err(duration_error(
450 "duration: non-scalar component inputs must have matching sizes",
451 ));
452 }
453 }
454 }
455
456 let mut broadcasted = Vec::with_capacity(arrays.len());
457 for (idx, array) in arrays.iter().enumerate() {
458 if array.data.len() == 1 {
459 broadcasted.push(vec![array.data[0]; target_len]);
460 } else if array.data.len() == target_len {
461 broadcasted.push(array.data.clone());
462 } else {
463 return Err(duration_error(format!(
464 "duration: {} input size does not match the other components",
465 labels[idx]
466 )));
467 }
468 }
469
470 Ok((broadcasted, target_shape))
471}
472
473fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
474 let labels = ["hours", "minutes", "seconds"];
475 let mut arrays = Vec::with_capacity(args.len());
476 for (idx, arg) in args.into_iter().enumerate() {
477 arrays.push(component_tensor(arg, labels[idx])?);
478 }
479 while arrays.len() < 3 {
480 arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
481 }
482
483 let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
484 let len = broadcasted[0].len();
485 let mut days = Vec::with_capacity(len);
486 for idx in 0..len {
487 let total_seconds =
488 broadcasted[0][idx] * 3600.0 + broadcasted[1][idx] * 60.0 + broadcasted[2][idx];
489 if !total_seconds.is_finite() {
490 return Err(duration_error("duration: component values must be finite"));
491 }
492 days.push(total_seconds / SECONDS_PER_DAY);
493 }
494
495 duration_object_from_days(
496 days,
497 shape,
498 format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
499 )
500}
501
502fn format_seconds_field(seconds: f64) -> String {
503 let whole = seconds.floor();
504 let fractional = seconds - whole;
505 if fractional.abs() <= 1e-9 {
506 format!("{:02}", whole as i64)
507 } else {
508 let mut text = format!("{:06.3}", seconds);
509 while text.contains('.') && text.ends_with('0') {
510 text.pop();
511 }
512 if text.ends_with('.') {
513 text.pop();
514 }
515 text
516 }
517}
518
519fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
520 if !days.is_finite() {
521 return Err(duration_error("duration: values must be finite"));
522 }
523
524 let total_seconds = days * SECONDS_PER_DAY;
525 let sign = if total_seconds < 0.0 { "-" } else { "" };
526 let total_seconds = total_seconds.abs();
527 let total_hours = (total_seconds / 3600.0).floor();
528 let total_minutes = (total_seconds / 60.0).floor();
529 let hours = total_hours as i64;
530 let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
531 let seconds_component =
532 total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
533
534 let rendered = match format {
535 "hh:mm:ss" => format!(
536 "{sign}{hours:02}:{minutes_component:02}:{}",
537 format_seconds_field(seconds_component)
538 ),
539 "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
540 "mm:ss" => format!(
541 "{sign}{:02}:{}",
542 total_minutes as i64,
543 format_seconds_field(total_seconds - total_minutes * 60.0)
544 ),
545 "s" | "ss" => {
546 let mut text = format!("{:.3}", total_seconds);
547 while text.contains('.') && text.ends_with('0') {
548 text.pop();
549 }
550 if text.ends_with('.') {
551 text.pop();
552 }
553 format!("{sign}{text}")
554 }
555 other => {
556 return Err(duration_error(format!(
557 "duration: unsupported Format value '{other}'"
558 )))
559 }
560 };
561
562 Ok(rendered)
563}
564
565pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
566 let Value::Object(obj) = value else {
567 return Ok(None);
568 };
569 if !obj.is_class(DURATION_CLASS) {
570 return Ok(None);
571 }
572 let days = duration_tensor_from_duration_value(value)?;
573 let format = format_for_object(obj);
574 let mut strings = Vec::with_capacity(days.data.len());
575 for value in &days.data {
576 strings.push(format_duration_value(*value, &format)?);
577 }
578 let shape = tensor::default_shape_for(&days.shape, days.data.len());
579 let array = StringArray::new(strings, shape)
580 .map_err(|err| duration_error(format!("duration: {err}")))?;
581 Ok(Some(array))
582}
583
584pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
585 let Some(array) = duration_string_array(value)? else {
586 return Ok(None);
587 };
588 if array.data.len() == 1 {
589 return Ok(Some(array.data[0].clone()));
590 }
591
592 let rows = array.rows;
593 let cols = array.cols;
594 let mut widths = vec![0usize; cols];
595 for col in 0..cols {
596 for row in 0..rows {
597 let idx = row + col * rows;
598 widths[col] = widths[col].max(array.data[idx].len());
599 }
600 }
601
602 let mut lines = Vec::with_capacity(rows);
603 for row in 0..rows {
604 let mut line = String::new();
605 for col in 0..cols {
606 if col > 0 {
607 line.push_str(" ");
608 }
609 let idx = row + col * rows;
610 let text = &array.data[idx];
611 line.push_str(text);
612 let padding = widths[col].saturating_sub(text.len());
613 if padding > 0 {
614 line.push_str(&" ".repeat(padding));
615 }
616 }
617 lines.push(line);
618 }
619
620 Ok(Some(lines.join("\n")))
621}
622
623pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
624 let Value::Object(obj) = value else {
625 return Ok(None);
626 };
627 if !obj.is_class(DURATION_CLASS) {
628 return Ok(None);
629 }
630 let days = duration_tensor_from_duration_value(value)?;
631 if days.data.len() == 1 {
632 return duration_display_text(value);
633 }
634 let shape = tensor::default_shape_for(&days.shape, days.data.len());
635 Ok(Some(format!(
636 "[{} duration]",
637 shape
638 .iter()
639 .map(|dim| dim.to_string())
640 .collect::<Vec<_>>()
641 .join("x")
642 )))
643}
644
645pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
646 let Some(array) = duration_string_array(value)? else {
647 return Ok(None);
648 };
649 let width = array.data.iter().map(String::len).max().unwrap_or(0);
650 let rows = array.data.len();
651 let mut data = vec![' '; rows * width];
652 for (row, text) in array.data.iter().enumerate() {
653 for (col, ch) in text.chars().enumerate() {
654 data[row * width + col] = ch;
655 }
656 }
657 let out = CharArray::new(data, rows, width)
658 .map_err(|err| duration_error(format!("duration: {err}")))?;
659 Ok(Some(out))
660}
661
662fn compare_duration(
663 lhs: Value,
664 rhs: Value,
665 op: &str,
666 cmp: impl Fn(f64, f64) -> bool,
667) -> BuiltinResult<Value> {
668 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
669 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
670 let (left, right, shape) =
671 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
672 let out = left
673 .iter()
674 .zip(right.iter())
675 .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
676 .collect::<Vec<_>>();
677 if out.len() == 1 {
678 Ok(Value::Num(out[0]))
679 } else {
680 Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
681 duration_error(format!("duration: {err}"))
682 })?))
683 }
684}
685
686async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
687 let Value::Object(object) = obj else {
688 return Err(duration_error(
689 "duration.subsref: receiver must be a duration object",
690 ));
691 };
692 let format = format_for_object(&object);
693 let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
694
695 let Value::Cell(cell) = payload else {
696 return Err(duration_error(
697 "duration.subsref: indexing payload must be a cell array",
698 ));
699 };
700 if cell.data.is_empty() {
701 return duration_object_from_days_tensor(days, format);
702 }
703 if cell.data.len() != 1 {
704 return Err(duration_error(
705 "duration.subsref: only linear duration indexing is currently supported",
706 ));
707 }
708 let selector = cell.data[0].clone();
709 let selector = match selector {
710 Value::Tensor(tensor) => tensor,
711 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
712 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
713 Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
714 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
715 Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
716 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
717 other => {
718 return Err(duration_error(format!(
719 "duration.subsref: unsupported index value {other:?}"
720 )))
721 }
722 };
723 let indexed = crate::perform_indexing(&Value::Tensor(days), &selector.data)
724 .await
725 .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
726 let indexed_days = match indexed {
727 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
728 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
729 Value::Tensor(tensor) => tensor,
730 other => {
731 return Err(duration_error(format!(
732 "duration.subsref: unexpected indexing result {other:?}"
733 )))
734 }
735 };
736 duration_object_from_days_tensor(indexed_days, format)
737}
738
739#[runmat_macros::runtime_builtin(
740 name = "duration",
741 descriptor(crate::builtins::duration::DURATION_DESCRIPTOR),
742 builtin_path = "crate::builtins::duration",
743 category = "datetime",
744 summary = "Create duration arrays from hour, minute, and second components.",
745 keywords = "duration,time span,elapsed time,Format",
746 related = "datetime,string,char,disp",
747 examples = "t = duration(1, 30, 45);"
748)]
749async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
750 ensure_duration_class_registered();
751 let args = gather_args(&args).await?;
752 let (positional_end, format) = parse_trailing_format(&args)?;
753 let positional = args[..positional_end].to_vec();
754
755 match positional.len() {
756 1..=3 => build_from_components(positional, format),
757 _ => Err(duration_error(
758 "duration: unsupported argument pattern; use H/M/S numeric component inputs",
759 )),
760 }
761}
762
763#[runmat_macros::runtime_builtin(
764 name = "duration.subsref",
765 descriptor(crate::builtins::duration::DURATION_SUBSREF_DESCRIPTOR),
766 builtin_path = "crate::builtins::duration"
767)]
768async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
769 match kind.as_str() {
770 OBJECT_INDEX_PAREN => duration_indexing(obj, payload).await,
771 OBJECT_INDEX_MEMBER => {
772 let Value::Object(object) = obj else {
773 return Err(duration_error(
774 "duration.subsref: receiver must be a duration object",
775 ));
776 };
777 let field = scalar_text(&payload, "field selector")?;
778 match field.as_str() {
779 FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
780 _ => Err(duration_error(format!(
781 "duration.subsref: unsupported duration property '{field}'"
782 ))),
783 }
784 }
785 other => Err(duration_error(format!(
786 "duration.subsref: unsupported indexing kind '{other}'"
787 ))),
788 }
789}
790
791#[runmat_macros::runtime_builtin(
792 name = "duration.subsasgn",
793 descriptor(crate::builtins::duration::DURATION_SUBSASGN_DESCRIPTOR),
794 builtin_path = "crate::builtins::duration"
795)]
796async fn duration_subsasgn(
797 obj: Value,
798 kind: String,
799 payload: Value,
800 rhs: Value,
801) -> crate::BuiltinResult<Value> {
802 let Value::Object(mut object) = obj else {
803 return Err(duration_error(
804 "duration.subsasgn: receiver must be a duration object",
805 ));
806 };
807 match kind.as_str() {
808 OBJECT_INDEX_MEMBER => {
809 let field = scalar_text(&payload, "field selector")?;
810 match field.as_str() {
811 FORMAT_FIELD => {
812 let text = scalar_text(&rhs, "Format value")?;
813 object
814 .properties
815 .insert(FORMAT_FIELD.to_string(), Value::String(text));
816 Ok(Value::Object(object))
817 }
818 _ => Err(duration_error(format!(
819 "duration.subsasgn: unsupported duration property '{field}'"
820 ))),
821 }
822 }
823 _ => Err(duration_error(format!(
824 "duration.subsasgn: unsupported indexing kind '{kind}'"
825 ))),
826 }
827}
828
829#[runmat_macros::runtime_builtin(
830 name = "duration.eq",
831 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
832 builtin_path = "crate::builtins::duration"
833)]
834async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
835 compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
836}
837
838#[runmat_macros::runtime_builtin(
839 name = "duration.ne",
840 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
841 builtin_path = "crate::builtins::duration"
842)]
843async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
844 compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
845}
846
847#[runmat_macros::runtime_builtin(
848 name = "duration.lt",
849 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
850 builtin_path = "crate::builtins::duration"
851)]
852async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
853 compare_duration(lhs, rhs, "lt", |a, b| a < b)
854}
855
856#[runmat_macros::runtime_builtin(
857 name = "duration.le",
858 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
859 builtin_path = "crate::builtins::duration"
860)]
861async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
862 compare_duration(lhs, rhs, "le", |a, b| a <= b)
863}
864
865#[runmat_macros::runtime_builtin(
866 name = "duration.gt",
867 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
868 builtin_path = "crate::builtins::duration"
869)]
870async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
871 compare_duration(lhs, rhs, "gt", |a, b| a > b)
872}
873
874#[runmat_macros::runtime_builtin(
875 name = "duration.ge",
876 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
877 builtin_path = "crate::builtins::duration"
878)]
879async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
880 compare_duration(lhs, rhs, "ge", |a, b| a >= b)
881}
882
883#[runmat_macros::runtime_builtin(
884 name = "duration.plus",
885 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
886 builtin_path = "crate::builtins::duration"
887)]
888async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
889 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
890 if crate::builtins::datetime::is_datetime_object(&rhs) {
891 let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
892 let (left, right, shape) =
893 tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
894 let serials = left
895 .iter()
896 .zip(right.iter())
897 .map(|(a, b)| a + b)
898 .collect::<Vec<_>>();
899 let tensor =
900 Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
901 return crate::builtins::datetime::datetime_object_from_serial_tensor(
902 tensor,
903 crate::builtins::datetime::datetime_format_from_value(&rhs),
904 );
905 }
906
907 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
908 let (left, right, shape) =
909 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
910 let days = left
911 .iter()
912 .zip(right.iter())
913 .map(|(a, b)| a + b)
914 .collect::<Vec<_>>();
915 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
916}
917
918#[runmat_macros::runtime_builtin(
919 name = "duration.minus",
920 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
921 builtin_path = "crate::builtins::duration"
922)]
923async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
924 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
925 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
926 let (left, right, shape) =
927 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
928 let days = left
929 .iter()
930 .zip(right.iter())
931 .map(|(a, b)| a - b)
932 .collect::<Vec<_>>();
933 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
934}
935
936#[cfg(test)]
937mod tests {
938 use super::*;
939
940 fn run_duration(args: Vec<Value>) -> Value {
941 futures::executor::block_on(duration_builtin(args)).expect("duration")
942 }
943
944 #[test]
945 fn duration_descriptor_signatures_cover_constructor_and_methods() {
946 let labels: Vec<&str> = DURATION_DESCRIPTOR
947 .signatures
948 .iter()
949 .map(|sig| sig.label)
950 .collect();
951 assert!(labels.contains(&"t = duration(hours)"));
952 assert!(labels.contains(&"t = duration(hours, minutes, seconds)"));
953 assert!(labels.contains(&"t = duration(___, \"Format\", format)"));
954 assert_eq!(
955 DURATION_SUBSREF_DESCRIPTOR.signatures[0].label,
956 "out = duration.subsref(obj, kind, payload)"
957 );
958 assert_eq!(
959 DURATION_BINARY_DESCRIPTOR.signatures[0].label,
960 "out = duration.op(lhs, rhs)"
961 );
962 }
963
964 #[test]
965 fn duration_builds_from_components() {
966 let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
967 let rendered = duration_display_text(&value)
968 .expect("display")
969 .expect("duration text");
970 assert_eq!(rendered, "01:30:45");
971 }
972
973 #[test]
974 fn duration_formats_arrays() {
975 let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
976 let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
977 let value = run_duration(vec![hours, minutes]);
978 let rendered = duration_display_text(&value)
979 .expect("display")
980 .expect("duration text");
981 assert!(rendered.contains("01:15:00"));
982 assert!(rendered.contains("02:45:00"));
983 }
984
985 #[test]
986 fn duration_supports_format_assignment_and_indexing() {
987 let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0)]);
988 let updated = futures::executor::block_on(duration_subsasgn(
989 value.clone(),
990 ".".to_string(),
991 Value::String(FORMAT_FIELD.to_string()),
992 Value::String("hh:mm".to_string()),
993 ))
994 .expect("subsasgn");
995 let rendered = duration_display_text(&updated)
996 .expect("display")
997 .expect("duration text");
998 assert_eq!(rendered, "01:05");
999
1000 let array = run_duration(vec![
1001 Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1002 Value::Num(0.0),
1003 Value::Num(0.0),
1004 ]);
1005 let payload =
1006 Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1007 let indexed =
1008 futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1009 .expect("subsref");
1010 let text = duration_display_text(&indexed)
1011 .expect("display")
1012 .expect("duration text");
1013 assert_eq!(text, "02:00:00");
1014 }
1015}