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
434async fn duration_unit_value(
435 value: Value,
436 unit_name: &str,
437 days_per_unit: f64,
438) -> BuiltinResult<Value> {
439 let value = gather_if_needed_async(&value)
440 .await
441 .map_err(|err| duration_error(format!("{unit_name}: {}", err.message())))?;
442 if is_duration_object(&value) {
443 let days = duration_tensor_from_duration_value(&value)?;
444 let data = days
445 .data
446 .iter()
447 .map(|day| day / days_per_unit)
448 .collect::<Vec<_>>();
449 return if data.len() == 1 {
450 Ok(Value::Num(data[0]))
451 } else {
452 Ok(Value::Tensor(
453 Tensor::new(
454 data,
455 tensor::default_shape_for(&days.shape, days.data.len()),
456 )
457 .map_err(|err| duration_error(format!("{unit_name}: {err}")))?,
458 ))
459 };
460 }
461 let numeric = component_tensor(value, unit_name)?;
462 let days = numeric
463 .data
464 .iter()
465 .map(|value| {
466 if !value.is_finite() {
467 Err(duration_error(format!(
468 "{unit_name}: values must be finite"
469 )))
470 } else {
471 let days = value * days_per_unit;
472 if days.is_finite() {
473 Ok(days)
474 } else {
475 Err(duration_error(format!(
476 "{unit_name}: resulting duration is outside supported range"
477 )))
478 }
479 }
480 })
481 .collect::<BuiltinResult<Vec<_>>>()?;
482 duration_object_from_days(
483 days,
484 tensor::default_shape_for(&numeric.shape, numeric.data.len()),
485 DEFAULT_DURATION_FORMAT,
486 )
487}
488
489fn broadcast_component_data(
490 arrays: &[Tensor],
491 labels: &[&str],
492) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
493 let mut target_shape = vec![1, 1];
494 let mut target_len = 1usize;
495
496 for array in arrays {
497 let len = array.data.len();
498 if len > 1 {
499 let shape = tensor::default_shape_for(&array.shape, len);
500 if target_len == 1 {
501 target_len = len;
502 target_shape = shape;
503 } else if len != target_len || shape != target_shape {
504 return Err(duration_error(
505 "duration: non-scalar component inputs must have matching sizes",
506 ));
507 }
508 }
509 }
510
511 let mut broadcasted = Vec::with_capacity(arrays.len());
512 for (idx, array) in arrays.iter().enumerate() {
513 if array.data.len() == 1 {
514 broadcasted.push(vec![array.data[0]; target_len]);
515 } else if array.data.len() == target_len {
516 broadcasted.push(array.data.clone());
517 } else {
518 return Err(duration_error(format!(
519 "duration: {} input size does not match the other components",
520 labels[idx]
521 )));
522 }
523 }
524
525 Ok((broadcasted, target_shape))
526}
527
528fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
529 let labels = ["hours", "minutes", "seconds"];
530 let mut arrays = Vec::with_capacity(args.len());
531 for (idx, arg) in args.into_iter().enumerate() {
532 arrays.push(component_tensor(arg, labels[idx])?);
533 }
534 while arrays.len() < 3 {
535 arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
536 }
537
538 let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
539 let len = broadcasted[0].len();
540 let mut days = Vec::with_capacity(len);
541 for idx in 0..len {
542 let total_seconds =
543 broadcasted[0][idx] * 3600.0 + broadcasted[1][idx] * 60.0 + broadcasted[2][idx];
544 if !total_seconds.is_finite() {
545 return Err(duration_error("duration: component values must be finite"));
546 }
547 days.push(total_seconds / SECONDS_PER_DAY);
548 }
549
550 duration_object_from_days(
551 days,
552 shape,
553 format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
554 )
555}
556
557fn format_seconds_field(seconds: f64) -> String {
558 let whole = seconds.floor();
559 let fractional = seconds - whole;
560 if fractional.abs() <= 1e-9 {
561 format!("{:02}", whole as i64)
562 } else {
563 let mut text = format!("{:06.3}", seconds);
564 while text.contains('.') && text.ends_with('0') {
565 text.pop();
566 }
567 if text.ends_with('.') {
568 text.pop();
569 }
570 text
571 }
572}
573
574fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
575 if days.is_nan() {
576 return Ok("NaN".to_string());
577 }
578 if !days.is_finite() {
579 return Err(duration_error("duration: values must be finite"));
580 }
581
582 let total_seconds = days * SECONDS_PER_DAY;
583 let sign = if total_seconds < 0.0 { "-" } else { "" };
584 let total_seconds = total_seconds.abs();
585 let total_hours = (total_seconds / 3600.0).floor();
586 let total_minutes = (total_seconds / 60.0).floor();
587 let hours = total_hours as i64;
588 let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
589 let seconds_component =
590 total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
591
592 let rendered = match format {
593 "hh:mm:ss" => format!(
594 "{sign}{hours:02}:{minutes_component:02}:{}",
595 format_seconds_field(seconds_component)
596 ),
597 "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
598 "mm:ss" => format!(
599 "{sign}{:02}:{}",
600 total_minutes as i64,
601 format_seconds_field(total_seconds - total_minutes * 60.0)
602 ),
603 "s" | "ss" => {
604 let mut text = format!("{:.3}", total_seconds);
605 while text.contains('.') && text.ends_with('0') {
606 text.pop();
607 }
608 if text.ends_with('.') {
609 text.pop();
610 }
611 format!("{sign}{text}")
612 }
613 other => {
614 return Err(duration_error(format!(
615 "duration: unsupported Format value '{other}'"
616 )))
617 }
618 };
619
620 Ok(rendered)
621}
622
623pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
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 let format = format_for_object(obj);
632 let mut strings = Vec::with_capacity(days.data.len());
633 for value in &days.data {
634 strings.push(format_duration_value(*value, &format)?);
635 }
636 let shape = tensor::default_shape_for(&days.shape, days.data.len());
637 let array = StringArray::new(strings, shape)
638 .map_err(|err| duration_error(format!("duration: {err}")))?;
639 Ok(Some(array))
640}
641
642pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
643 let Some(array) = duration_string_array(value)? else {
644 return Ok(None);
645 };
646 if array.data.len() == 1 {
647 return Ok(Some(array.data[0].clone()));
648 }
649
650 let rows = array.rows;
651 let cols = array.cols;
652 let mut widths = vec![0usize; cols];
653 for col in 0..cols {
654 for row in 0..rows {
655 let idx = row + col * rows;
656 widths[col] = widths[col].max(array.data[idx].len());
657 }
658 }
659
660 let mut lines = Vec::with_capacity(rows);
661 for row in 0..rows {
662 let mut line = String::new();
663 for col in 0..cols {
664 if col > 0 {
665 line.push_str(" ");
666 }
667 let idx = row + col * rows;
668 let text = &array.data[idx];
669 line.push_str(text);
670 let padding = widths[col].saturating_sub(text.len());
671 if padding > 0 {
672 line.push_str(&" ".repeat(padding));
673 }
674 }
675 lines.push(line);
676 }
677
678 Ok(Some(lines.join("\n")))
679}
680
681pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
682 let Value::Object(obj) = value else {
683 return Ok(None);
684 };
685 if !obj.is_class(DURATION_CLASS) {
686 return Ok(None);
687 }
688 let days = duration_tensor_from_duration_value(value)?;
689 if days.data.len() == 1 {
690 return duration_display_text(value);
691 }
692 let shape = tensor::default_shape_for(&days.shape, days.data.len());
693 Ok(Some(format!(
694 "[{} duration]",
695 shape
696 .iter()
697 .map(|dim| dim.to_string())
698 .collect::<Vec<_>>()
699 .join("x")
700 )))
701}
702
703pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
704 let Some(array) = duration_string_array(value)? else {
705 return Ok(None);
706 };
707 let width = array.data.iter().map(String::len).max().unwrap_or(0);
708 let rows = array.data.len();
709 let mut data = vec![' '; rows * width];
710 for (row, text) in array.data.iter().enumerate() {
711 for (col, ch) in text.chars().enumerate() {
712 data[row * width + col] = ch;
713 }
714 }
715 let out = CharArray::new(data, rows, width)
716 .map_err(|err| duration_error(format!("duration: {err}")))?;
717 Ok(Some(out))
718}
719
720fn compare_duration(
721 lhs: Value,
722 rhs: Value,
723 op: &str,
724 cmp: impl Fn(f64, f64) -> bool,
725) -> BuiltinResult<Value> {
726 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
727 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
728 let (left, right, shape) =
729 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
730 let out = left
731 .iter()
732 .zip(right.iter())
733 .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
734 .collect::<Vec<_>>();
735 if out.len() == 1 {
736 Ok(Value::Num(out[0]))
737 } else {
738 Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
739 duration_error(format!("duration: {err}"))
740 })?))
741 }
742}
743
744async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
745 let Value::Object(object) = obj else {
746 return Err(duration_error(
747 "duration.subsref: receiver must be a duration object",
748 ));
749 };
750 let format = format_for_object(&object);
751 let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
752
753 let Value::Cell(cell) = payload else {
754 return Err(duration_error(
755 "duration.subsref: indexing payload must be a cell array",
756 ));
757 };
758 if cell.data.is_empty() {
759 return duration_object_from_days_tensor(days, format);
760 }
761 if cell.data.len() != 1 {
762 return Err(duration_error(
763 "duration.subsref: only linear duration indexing is currently supported",
764 ));
765 }
766 let selector = cell.data[0].clone();
767 let selector = match selector {
768 Value::Tensor(tensor) => tensor,
769 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
770 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
771 Value::Int(value) => Tensor::new(vec![value.to_f64()], vec![1, 1])
772 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
773 Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
774 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
775 other => {
776 return Err(duration_error(format!(
777 "duration.subsref: unsupported index value {other:?}"
778 )))
779 }
780 };
781 let indexed = crate::perform_indexing(&Value::Tensor(days), &selector.data)
782 .await
783 .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
784 let indexed_days = match indexed {
785 Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
786 .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
787 Value::Tensor(tensor) => tensor,
788 other => {
789 return Err(duration_error(format!(
790 "duration.subsref: unexpected indexing result {other:?}"
791 )))
792 }
793 };
794 duration_object_from_days_tensor(indexed_days, format)
795}
796
797#[runmat_macros::runtime_builtin(
798 name = "duration",
799 descriptor(crate::builtins::duration::DURATION_DESCRIPTOR),
800 builtin_path = "crate::builtins::duration",
801 category = "datetime",
802 summary = "Create duration arrays from hour, minute, and second components.",
803 keywords = "duration,time span,elapsed time,Format",
804 related = "datetime,string,char,disp",
805 examples = "t = duration(1, 30, 45);"
806)]
807async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
808 ensure_duration_class_registered();
809 let args = gather_args(&args).await?;
810 let (positional_end, format) = parse_trailing_format(&args)?;
811 let positional = args[..positional_end].to_vec();
812
813 match positional.len() {
814 1..=3 => build_from_components(positional, format),
815 _ => Err(duration_error(
816 "duration: unsupported argument pattern; use H/M/S numeric component inputs",
817 )),
818 }
819}
820
821#[runmat_macros::runtime_builtin(
822 name = "days",
823 builtin_path = "crate::builtins::duration",
824 category = "datetime",
825 summary = "Create duration values from days or convert duration values to day counts.",
826 keywords = "days,duration,datetime"
827)]
828async fn days_builtin(value: Value) -> crate::BuiltinResult<Value> {
829 duration_unit_value(value, "days", 1.0).await
830}
831
832#[runmat_macros::runtime_builtin(
833 name = "hours",
834 builtin_path = "crate::builtins::duration",
835 category = "datetime",
836 summary = "Create duration values from hours or convert duration values to hour counts.",
837 keywords = "hours,duration,datetime"
838)]
839async fn hours_builtin(value: Value) -> crate::BuiltinResult<Value> {
840 duration_unit_value(value, "hours", 1.0 / 24.0).await
841}
842
843#[runmat_macros::runtime_builtin(
844 name = "minutes",
845 builtin_path = "crate::builtins::duration",
846 category = "datetime",
847 summary = "Create duration values from minutes or convert duration values to minute counts.",
848 keywords = "minutes,duration,datetime"
849)]
850async fn minutes_builtin(value: Value) -> crate::BuiltinResult<Value> {
851 duration_unit_value(value, "minutes", 1.0 / (24.0 * 60.0)).await
852}
853
854#[runmat_macros::runtime_builtin(
855 name = "seconds",
856 builtin_path = "crate::builtins::duration",
857 category = "datetime",
858 summary = "Create duration values from seconds or convert duration values to second counts.",
859 keywords = "seconds,duration,datetime"
860)]
861async fn seconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
862 duration_unit_value(value, "seconds", 1.0 / SECONDS_PER_DAY).await
863}
864
865#[runmat_macros::runtime_builtin(
866 name = "milliseconds",
867 builtin_path = "crate::builtins::duration",
868 category = "datetime",
869 summary = "Create duration values from milliseconds or convert duration values to millisecond counts.",
870 keywords = "milliseconds,duration,datetime"
871)]
872async fn milliseconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
873 duration_unit_value(value, "milliseconds", 1.0 / (SECONDS_PER_DAY * 1000.0)).await
874}
875
876#[runmat_macros::runtime_builtin(
877 name = "years",
878 builtin_path = "crate::builtins::duration",
879 category = "datetime",
880 summary = "Create fixed-length duration values from years or convert durations to fixed-length years.",
881 keywords = "years,duration,datetime"
882)]
883async fn years_builtin(value: Value) -> crate::BuiltinResult<Value> {
884 duration_unit_value(value, "years", 365.2425).await
885}
886
887#[runmat_macros::runtime_builtin(
888 name = "isduration",
889 builtin_path = "crate::builtins::duration",
890 category = "datetime",
891 summary = "Return true for duration values.",
892 keywords = "isduration,duration,predicate"
893)]
894fn isduration_builtin(value: Value) -> crate::BuiltinResult<Value> {
895 Ok(Value::Bool(is_duration_object(&value)))
896}
897
898#[runmat_macros::runtime_builtin(
899 name = "duration.subsref",
900 descriptor(crate::builtins::duration::DURATION_SUBSREF_DESCRIPTOR),
901 builtin_path = "crate::builtins::duration"
902)]
903async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
904 match kind.as_str() {
905 OBJECT_INDEX_PAREN => duration_indexing(obj, payload).await,
906 OBJECT_INDEX_MEMBER => {
907 let Value::Object(object) = obj else {
908 return Err(duration_error(
909 "duration.subsref: receiver must be a duration object",
910 ));
911 };
912 let field = scalar_text(&payload, "field selector")?;
913 match field.as_str() {
914 FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
915 _ => Err(duration_error(format!(
916 "duration.subsref: unsupported duration property '{field}'"
917 ))),
918 }
919 }
920 other => Err(duration_error(format!(
921 "duration.subsref: unsupported indexing kind '{other}'"
922 ))),
923 }
924}
925
926#[runmat_macros::runtime_builtin(
927 name = "duration.subsasgn",
928 descriptor(crate::builtins::duration::DURATION_SUBSASGN_DESCRIPTOR),
929 builtin_path = "crate::builtins::duration"
930)]
931async fn duration_subsasgn(
932 obj: Value,
933 kind: String,
934 payload: Value,
935 rhs: Value,
936) -> crate::BuiltinResult<Value> {
937 let Value::Object(mut object) = obj else {
938 return Err(duration_error(
939 "duration.subsasgn: receiver must be a duration object",
940 ));
941 };
942 match kind.as_str() {
943 OBJECT_INDEX_MEMBER => {
944 let field = scalar_text(&payload, "field selector")?;
945 match field.as_str() {
946 FORMAT_FIELD => {
947 let text = scalar_text(&rhs, "Format value")?;
948 object
949 .properties
950 .insert(FORMAT_FIELD.to_string(), Value::String(text));
951 Ok(Value::Object(object))
952 }
953 _ => Err(duration_error(format!(
954 "duration.subsasgn: unsupported duration property '{field}'"
955 ))),
956 }
957 }
958 _ => Err(duration_error(format!(
959 "duration.subsasgn: unsupported indexing kind '{kind}'"
960 ))),
961 }
962}
963
964#[runmat_macros::runtime_builtin(
965 name = "duration.eq",
966 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
967 builtin_path = "crate::builtins::duration"
968)]
969async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
970 compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
971}
972
973#[runmat_macros::runtime_builtin(
974 name = "duration.ne",
975 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
976 builtin_path = "crate::builtins::duration"
977)]
978async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
979 compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
980}
981
982#[runmat_macros::runtime_builtin(
983 name = "duration.lt",
984 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
985 builtin_path = "crate::builtins::duration"
986)]
987async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
988 compare_duration(lhs, rhs, "lt", |a, b| a < b)
989}
990
991#[runmat_macros::runtime_builtin(
992 name = "duration.le",
993 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
994 builtin_path = "crate::builtins::duration"
995)]
996async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
997 compare_duration(lhs, rhs, "le", |a, b| a <= b)
998}
999
1000#[runmat_macros::runtime_builtin(
1001 name = "duration.gt",
1002 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1003 builtin_path = "crate::builtins::duration"
1004)]
1005async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1006 compare_duration(lhs, rhs, "gt", |a, b| a > b)
1007}
1008
1009#[runmat_macros::runtime_builtin(
1010 name = "duration.ge",
1011 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1012 builtin_path = "crate::builtins::duration"
1013)]
1014async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1015 compare_duration(lhs, rhs, "ge", |a, b| a >= b)
1016}
1017
1018#[runmat_macros::runtime_builtin(
1019 name = "duration.plus",
1020 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1021 builtin_path = "crate::builtins::duration"
1022)]
1023async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1024 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1025 if crate::builtins::datetime::is_datetime_object(&rhs) {
1026 let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
1027 let (left, right, shape) =
1028 tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
1029 let serials = left
1030 .iter()
1031 .zip(right.iter())
1032 .map(|(a, b)| a + b)
1033 .collect::<Vec<_>>();
1034 let tensor =
1035 Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
1036 return crate::builtins::datetime::datetime_object_from_serial_tensor(
1037 tensor,
1038 crate::builtins::datetime::datetime_format_from_value(&rhs),
1039 );
1040 }
1041
1042 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1043 let (left, right, shape) =
1044 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
1045 let days = left
1046 .iter()
1047 .zip(right.iter())
1048 .map(|(a, b)| a + b)
1049 .collect::<Vec<_>>();
1050 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1051}
1052
1053#[runmat_macros::runtime_builtin(
1054 name = "duration.minus",
1055 descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1056 builtin_path = "crate::builtins::duration"
1057)]
1058async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1059 let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1060 let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1061 let (left, right, shape) =
1062 tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
1063 let days = left
1064 .iter()
1065 .zip(right.iter())
1066 .map(|(a, b)| a - b)
1067 .collect::<Vec<_>>();
1068 duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1069}
1070
1071#[cfg(test)]
1072mod tests {
1073 use super::*;
1074
1075 fn run_duration(args: Vec<Value>) -> Value {
1076 futures::executor::block_on(duration_builtin(args)).expect("duration")
1077 }
1078
1079 #[test]
1080 fn duration_descriptor_signatures_cover_constructor_and_methods() {
1081 let labels: Vec<&str> = DURATION_DESCRIPTOR
1082 .signatures
1083 .iter()
1084 .map(|sig| sig.label)
1085 .collect();
1086 assert!(labels.contains(&"t = duration(hours)"));
1087 assert!(labels.contains(&"t = duration(hours, minutes, seconds)"));
1088 assert!(labels.contains(&"t = duration(___, \"Format\", format)"));
1089 assert_eq!(
1090 DURATION_SUBSREF_DESCRIPTOR.signatures[0].label,
1091 "out = duration.subsref(obj, kind, payload)"
1092 );
1093 assert_eq!(
1094 DURATION_BINARY_DESCRIPTOR.signatures[0].label,
1095 "out = duration.op(lhs, rhs)"
1096 );
1097 }
1098
1099 #[test]
1100 fn duration_builds_from_components() {
1101 let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
1102 let rendered = duration_display_text(&value)
1103 .expect("display")
1104 .expect("duration text");
1105 assert_eq!(rendered, "01:30:45");
1106 }
1107
1108 #[test]
1109 fn duration_formats_arrays() {
1110 let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
1111 let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
1112 let value = run_duration(vec![hours, minutes]);
1113 let rendered = duration_display_text(&value)
1114 .expect("display")
1115 .expect("duration text");
1116 assert!(rendered.contains("01:15:00"));
1117 assert!(rendered.contains("02:45:00"));
1118 }
1119
1120 #[test]
1121 fn duration_missing_days_render_without_error() {
1122 let value = duration_object_from_days_tensor(
1123 Tensor::new(vec![f64::NAN], vec![1, 1]).unwrap(),
1124 DEFAULT_DURATION_FORMAT,
1125 )
1126 .expect("duration object");
1127 let rendered = duration_string_array(&value)
1128 .expect("string array")
1129 .expect("duration strings");
1130 assert_eq!(rendered.data, vec!["NaN".to_string()]);
1131 assert_eq!(
1132 duration_display_text(&value).expect("display"),
1133 Some("NaN".to_string())
1134 );
1135 }
1136
1137 #[test]
1138 fn duration_unit_helpers_create_and_convert_values() {
1139 let one_day = futures::executor::block_on(days_builtin(Value::Num(1.0))).expect("days");
1140 assert!(is_duration_object(&one_day));
1141 let as_hours = futures::executor::block_on(hours_builtin(one_day.clone())).expect("hours");
1142 assert_eq!(as_hours, Value::Num(24.0));
1143 let as_minutes =
1144 futures::executor::block_on(minutes_builtin(one_day.clone())).expect("minutes");
1145 assert_eq!(as_minutes, Value::Num(1440.0));
1146 let as_seconds =
1147 futures::executor::block_on(seconds_builtin(one_day.clone())).expect("seconds");
1148 assert_eq!(as_seconds, Value::Num(86_400.0));
1149 let as_millis =
1150 futures::executor::block_on(milliseconds_builtin(one_day.clone())).expect("millis");
1151 assert_eq!(as_millis, Value::Num(86_400_000.0));
1152
1153 let two_hours = futures::executor::block_on(hours_builtin(Value::Num(2.0))).expect("hours");
1154 let rendered = duration_display_text(&two_hours)
1155 .expect("display")
1156 .expect("duration text");
1157 assert_eq!(rendered, "02:00:00");
1158
1159 let year = futures::executor::block_on(years_builtin(Value::Num(1.0))).expect("years");
1160 let year_days = duration_tensor_from_duration_value(&year).expect("duration tensor");
1161 assert!((year_days.data[0] - 365.2425).abs() < 1e-9);
1162 assert_eq!(
1163 isduration_builtin(year).expect("isduration"),
1164 Value::Bool(true)
1165 );
1166 assert_eq!(
1167 isduration_builtin(Value::Num(1.0)).expect("isduration"),
1168 Value::Bool(false)
1169 );
1170 assert!(futures::executor::block_on(years_builtin(Value::Num(f64::MAX))).is_err());
1171 }
1172
1173 #[test]
1174 fn duration_supports_format_assignment_and_indexing() {
1175 let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0)]);
1176 let updated = futures::executor::block_on(duration_subsasgn(
1177 value.clone(),
1178 ".".to_string(),
1179 Value::String(FORMAT_FIELD.to_string()),
1180 Value::String("hh:mm".to_string()),
1181 ))
1182 .expect("subsasgn");
1183 let rendered = duration_display_text(&updated)
1184 .expect("display")
1185 .expect("duration text");
1186 assert_eq!(rendered, "01:05");
1187
1188 let array = run_duration(vec![
1189 Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1190 Value::Num(0.0),
1191 Value::Num(0.0),
1192 ]);
1193 let payload =
1194 Value::Cell(runmat_builtins::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1195 let indexed =
1196 futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1197 .expect("subsref");
1198 let text = duration_display_text(&indexed)
1199 .expect("display")
1200 .expect("duration text");
1201 assert_eq!(text, "02:00:00");
1202 }
1203}