Skip to main content

runmat_runtime/builtins/io/
input.rs

1//! MATLAB-compatible `input` builtin for line-oriented console interaction.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CharArray, LogicalArray, Tensor, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use crate::builtins::common::spec::{
11    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
12    ReductionNaN, ResidencyPolicy, ShapeRequirements,
13};
14use crate::interaction;
15use crate::{
16    build_runtime_error, call_builtin_async, gather_if_needed_async, BuiltinResult, RuntimeError,
17};
18
19const DEFAULT_PROMPT: &str = "Input: ";
20const BUILTIN_NAME: &str = "input";
21
22const INPUT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "value",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Parsed scalar/matrix value, or raw text when using string mode.",
28}];
29const INPUT_INPUTS_NONE: [BuiltinParamDescriptor; 0] = [];
30const INPUT_INPUTS_PROMPT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
31    name: "prompt",
32    ty: BuiltinParamType::Any,
33    arity: BuiltinParamArity::Required,
34    default: None,
35    description: "Prompt text shown to the user.",
36}];
37const INPUT_INPUTS_PROMPT_FLAG: [BuiltinParamDescriptor; 2] = [
38    BuiltinParamDescriptor {
39        name: "prompt",
40        ty: BuiltinParamType::Any,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Prompt text shown to the user.",
44    },
45    BuiltinParamDescriptor {
46        name: "stringFlag",
47        ty: BuiltinParamType::StringScalar,
48        arity: BuiltinParamArity::Required,
49        default: None,
50        description: "Set to 's' to return the raw input text.",
51    },
52];
53const INPUT_INPUTS_FLAG_PROMPT: [BuiltinParamDescriptor; 2] = [
54    BuiltinParamDescriptor {
55        name: "stringFlag",
56        ty: BuiltinParamType::StringScalar,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "Set to 's' to return the raw input text.",
60    },
61    BuiltinParamDescriptor {
62        name: "prompt",
63        ty: BuiltinParamType::Any,
64        arity: BuiltinParamArity::Required,
65        default: None,
66        description: "Prompt text shown to the user.",
67    },
68];
69const INPUT_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
70    BuiltinSignatureDescriptor {
71        label: "value = input()",
72        inputs: &INPUT_INPUTS_NONE,
73        outputs: &INPUT_OUTPUT,
74    },
75    BuiltinSignatureDescriptor {
76        label: "value = input(prompt)",
77        inputs: &INPUT_INPUTS_PROMPT,
78        outputs: &INPUT_OUTPUT,
79    },
80    BuiltinSignatureDescriptor {
81        label: "value = input(prompt, stringFlag)",
82        inputs: &INPUT_INPUTS_PROMPT_FLAG,
83        outputs: &INPUT_OUTPUT,
84    },
85    BuiltinSignatureDescriptor {
86        label: "value = input(stringFlag, prompt)",
87        inputs: &INPUT_INPUTS_FLAG_PROMPT,
88        outputs: &INPUT_OUTPUT,
89    },
90];
91const INPUT_ERROR_TOO_MANY_INPUTS: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
92    code: "RM.INPUT.TOO_MANY_INPUTS",
93    identifier: Some("RunMat:input:TooManyInputs"),
94    when: "More than two input arguments are passed to input.",
95    message: "input: too many inputs",
96};
97const INPUT_ERROR_INVALID_STRING_FLAG: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
98    code: "RM.INPUT.INVALID_STRING_FLAG",
99    identifier: Some("RunMat:input:InvalidStringFlag"),
100    when: "The string mode flag is not a scalar string/char 's'.",
101    message: "input: invalid string flag",
102};
103const INPUT_ERROR_PROMPT_ROW_VECTOR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
104    code: "RM.INPUT.PROMPT_ROW_VECTOR",
105    identifier: Some("RunMat:input:PromptMustBeRowVector"),
106    when: "Prompt char array is not 1-by-N.",
107    message: "input: prompt must be a row vector",
108};
109const INPUT_ERROR_PROMPT_SCALAR_STRING: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
110    code: "RM.INPUT.PROMPT_SCALAR_STRING",
111    identifier: Some("RunMat:input:PromptMustBeScalarString"),
112    when: "Prompt string array is not scalar.",
113    message: "input: prompt must be a scalar string",
114};
115const INPUT_ERROR_INVALID_PROMPT_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
116    code: "RM.INPUT.INVALID_PROMPT_TYPE",
117    identifier: Some("RunMat:input:InvalidPromptType"),
118    when: "Prompt is not a string scalar or row char vector.",
119    message: "input: invalid prompt type",
120};
121const INPUT_ERROR_INTERACTION_FAILED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
122    code: "RM.INPUT.INTERACTION_FAILED",
123    identifier: Some("RunMat:input:InteractionFailed"),
124    when: "Interactive prompt callback fails.",
125    message: "input: interaction failed",
126};
127const INPUT_ERROR_EVAL_FAILED: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128    code: "RM.INPUT.EVAL_FAILED",
129    identifier: Some("RunMat:input:EvalFailed"),
130    when: "Expression evaluation hook rejects the input expression.",
131    message: "input: invalid expression",
132};
133const INPUT_ERROR_INVALID_NUMERIC_EXPRESSION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
134    code: "RM.INPUT.INVALID_NUMERIC_EXPRESSION",
135    identifier: Some("RunMat:input:InvalidNumericExpression"),
136    when: "Numeric fallback parser rejects the input expression.",
137    message: "input: invalid numeric expression",
138};
139const INPUT_ERRORS: [BuiltinErrorDescriptor; 8] = [
140    INPUT_ERROR_TOO_MANY_INPUTS,
141    INPUT_ERROR_INVALID_STRING_FLAG,
142    INPUT_ERROR_PROMPT_ROW_VECTOR,
143    INPUT_ERROR_PROMPT_SCALAR_STRING,
144    INPUT_ERROR_INVALID_PROMPT_TYPE,
145    INPUT_ERROR_INTERACTION_FAILED,
146    INPUT_ERROR_EVAL_FAILED,
147    INPUT_ERROR_INVALID_NUMERIC_EXPRESSION,
148];
149pub const INPUT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
150    signatures: &INPUT_SIGNATURES,
151    output_mode: BuiltinOutputMode::Fixed,
152    completion_policy: BuiltinCompletionPolicy::Public,
153    errors: &INPUT_ERRORS,
154};
155
156fn input_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
157    input_error_with(error, error.message)
158}
159
160fn input_error_with(
161    error: &'static BuiltinErrorDescriptor,
162    message: impl Into<String>,
163) -> RuntimeError {
164    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
165    if let Some(identifier) = error.identifier {
166        builder = builder.with_identifier(identifier.to_string());
167    }
168    builder.build()
169}
170
171fn input_error_with_source(
172    error: &'static BuiltinErrorDescriptor,
173    message: impl Into<String>,
174    source: RuntimeError,
175) -> RuntimeError {
176    let mut builder = build_runtime_error(message)
177        .with_builtin(BUILTIN_NAME)
178        .with_source(source);
179    if let Some(identifier) = error.identifier {
180        builder = builder.with_identifier(identifier.to_string());
181    }
182    builder.build()
183}
184
185#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::input")]
186pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
187    name: "input",
188    op_kind: GpuOpKind::Custom("interaction"),
189    supported_precisions: &[],
190    broadcast: BroadcastSemantics::None,
191    provider_hooks: &[],
192    constant_strategy: ConstantStrategy::InlineLiteral,
193    residency: ResidencyPolicy::GatherImmediately,
194    nan_mode: ReductionNaN::Include,
195    two_pass_threshold: None,
196    workgroup_size: None,
197    accepts_nan_mode: false,
198    notes: "Prompts execute on the host. Input text is always delivered via the host handler; GPU tensors are only gathered when used as prompt strings.",
199};
200
201#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::input")]
202pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
203    name: "input",
204    shape: ShapeRequirements::Any,
205    constant_strategy: ConstantStrategy::InlineLiteral,
206    elementwise: None,
207    reduction: None,
208    emits_nan: false,
209    notes: "Side-effecting builtin; excluded from fusion plans.",
210};
211
212#[runtime_builtin(
213    name = "input",
214    summary = "Prompt users for interactive input.",
215    type_resolver(crate::builtins::io::type_resolvers::input_type),
216    descriptor(crate::builtins::io::input::INPUT_DESCRIPTOR),
217    builtin_path = "crate::builtins::io::input"
218)]
219async fn input_builtin(args: Vec<Value>) -> BuiltinResult<Value> {
220    if args.len() > 2 {
221        return Err(input_error(&INPUT_ERROR_TOO_MANY_INPUTS));
222    }
223
224    let mut prompt_index = if args.is_empty() { None } else { Some(0usize) };
225    let mut parsed_flag: Option<bool> = None;
226
227    if let Some(idx) = if args.len() == 2 { Some(1usize) } else { None } {
228        match parse_string_flag(&args[idx]).await {
229            Ok(flag) => parsed_flag = Some(flag),
230            Err(original_err) => {
231                if let Some(prompt_idx) = prompt_index {
232                    match parse_string_flag(&args[prompt_idx]).await {
233                        Ok(swapped_flag) => {
234                            parsed_flag = Some(swapped_flag);
235                            prompt_index = Some(idx);
236                        }
237                        Err(_) => {
238                            return Err(original_err);
239                        }
240                    }
241                } else {
242                    return Err(original_err);
243                }
244            }
245        }
246    }
247
248    let prompt = if let Some(idx) = prompt_index {
249        parse_prompt(&args[idx]).await?
250    } else {
251        DEFAULT_PROMPT.to_string()
252    };
253    let return_string = parsed_flag.unwrap_or(false);
254    let line = interaction::request_line_async(&prompt, true)
255        .await
256        .map_err(|err| {
257            let message = err.message().to_string();
258            input_error_with_source(
259                &INPUT_ERROR_INTERACTION_FAILED,
260                format!("input: {message}"),
261                err,
262            )
263        })?;
264    if return_string {
265        return Ok(Value::CharArray(CharArray::new_row(&line)));
266    }
267    parse_numeric_response(&line).await
268}
269
270async fn parse_prompt(value: &Value) -> Result<String, RuntimeError> {
271    let gathered = gather_if_needed_async(value).await?;
272    match gathered {
273        Value::CharArray(ca) => {
274            if ca.rows != 1 {
275                Err(input_error(&INPUT_ERROR_PROMPT_ROW_VECTOR))
276            } else {
277                Ok(ca.data.iter().collect())
278            }
279        }
280        Value::String(text) => Ok(text),
281        Value::StringArray(sa) => {
282            if sa.data.len() == 1 {
283                Ok(sa.data[0].clone())
284            } else {
285                Err(input_error(&INPUT_ERROR_PROMPT_SCALAR_STRING))
286            }
287        }
288        other => Err(input_error_with(
289            &INPUT_ERROR_INVALID_PROMPT_TYPE,
290            format!("input: invalid prompt type ({other:?})"),
291        )),
292    }
293}
294
295async fn parse_string_flag(value: &Value) -> Result<bool, RuntimeError> {
296    let gathered = gather_if_needed_async(value).await?;
297    let text = match gathered {
298        Value::CharArray(ca) if ca.rows == 1 => ca.data.iter().collect::<String>(),
299        Value::String(s) => s,
300        Value::StringArray(sa) if sa.data.len() == 1 => sa.data[0].clone(),
301        other => {
302            return Err(input_error_with(
303                &INPUT_ERROR_INVALID_STRING_FLAG,
304                format!("input: invalid string flag ({other:?})"),
305            ))
306        }
307    };
308    let trimmed = text.trim();
309    if trimmed.eq_ignore_ascii_case("s") {
310        Ok(true)
311    } else {
312        Err(input_error_with(
313            &INPUT_ERROR_INVALID_STRING_FLAG,
314            format!("input: invalid string flag ({trimmed})"),
315        ))
316    }
317}
318
319async fn parse_numeric_response(line: &str) -> Result<Value, RuntimeError> {
320    let trimmed = line.trim();
321    if trimmed.is_empty() || trimmed == "[]" {
322        return Ok(Value::Tensor(Tensor::zeros(vec![0, 0])));
323    }
324
325    // Fast path 1: scalar literals, named constants, and logical keywords.
326    // Handles the vast majority of input() use cases without touching the VM.
327    if let Some(v) = parse_scalar_value(trimmed) {
328        return Ok(v);
329    }
330
331    // Fast path 2: matrix/vector literals like `[1 2 3]`, `[1;2;3]`, `[true false]`.
332    // Avoids recursive interpret() calls for this common case.
333    if trimmed.starts_with('[') && trimmed.ends_with(']') {
334        if let Some(v) = parse_matrix_literal(trimmed) {
335            return Ok(v);
336        }
337    }
338
339    // Full eval path for complex expressions (`sqrt(2)`, `pi/2`, `ones(3)`, etc.).
340    // The eval hook is only safe to call when the executor can handle re-entrant
341    // polls (e.g. the WASM async runtime). On native the fast paths above cover
342    // the common cases; truly complex expressions fall back to str2double here.
343    if let Some(hook) = interaction::current_eval_hook() {
344        return hook(trimmed.to_string()).await.map_err(|err| {
345            let message = err.message().to_string();
346            input_error_with_source(
347                &INPUT_ERROR_EVAL_FAILED,
348                format!("input: invalid expression ({message})"),
349                err,
350            )
351        });
352    }
353
354    // Fallback when no eval hook is installed (unit tests, native REPL).
355    call_builtin_async("str2double", &[Value::String(trimmed.to_string())])
356        .await
357        .map_err(|err| {
358            let message = err.message().to_string();
359            input_error_with_source(
360                &INPUT_ERROR_INVALID_NUMERIC_EXPRESSION,
361                format!("input: invalid numeric expression ({message})"),
362                err,
363            )
364        })
365}
366
367/// Parse a single MATLAB scalar token into a [`Value`].
368///
369/// Returns [`Value::Bool`] for `true`/`false` (case-insensitive), [`Value::Num`]
370/// for numeric literals and named constants (`pi`, `inf`, `nan`), and
371/// `None` for anything that looks like a matrix, function call, or unknown
372/// identifier.
373///
374/// Note: `e` is intentionally **not** handled here. It is not a MATLAB built-in
375/// constant; typing `e` at an `input()` prompt would perform a variable lookup in
376/// MATLAB and error if `e` is undefined. Unknown identifiers fall through to the
377/// eval hook or `str2double`, which produce the correct error.
378fn parse_scalar_value(s: &str) -> Option<Value> {
379    match s.to_ascii_lowercase().as_str() {
380        "true" => return Some(Value::Bool(true)),
381        "false" => return Some(Value::Bool(false)),
382        "pi" => return Some(Value::Num(std::f64::consts::PI)),
383        "inf" | "+inf" | "infinity" | "+infinity" => return Some(Value::Num(f64::INFINITY)),
384        "-inf" | "-infinity" => return Some(Value::Num(f64::NEG_INFINITY)),
385        "nan" => return Some(Value::Num(f64::NAN)),
386        _ => {}
387    }
388    // Plain numeric literals: integers, decimals, scientific notation, optional sign.
389    // We reject anything containing brackets, commas, spaces (which would indicate a
390    // matrix or an expression), or letters other than 'e'/'E' for exponent notation.
391    let has_non_numeric = s.chars().any(|c| {
392        matches!(c, '[' | ']' | ',' | ';' | '(' | ')' | ' ' | '\t')
393            || (c.is_ascii_alphabetic() && c != 'e' && c != 'E' && c != 'i' && c != 'j')
394    });
395    if has_non_numeric {
396        return None;
397    }
398    s.parse::<f64>().ok().map(Value::Num)
399}
400
401/// Parse a MATLAB matrix literal of the form `[elements]`.
402///
403/// Rows are separated by `;` and elements within a row by whitespace and/or `,`.
404/// Every element must be a token accepted by [`parse_scalar_value`].
405/// Returns `None` if the literal is malformed or contains non-scalar elements.
406///
407/// Output type mirrors MATLAB semantics:
408/// - All-logical elements → [`Value::LogicalArray`]
409/// - Any numeric element  → [`Value::Tensor`] (logical elements coerced to `f64`)
410fn parse_matrix_literal(s: &str) -> Option<Value> {
411    let inner = s.strip_prefix('[')?.strip_suffix(']')?;
412    let inner = inner.trim();
413    if inner.is_empty() {
414        return Some(Value::Tensor(Tensor::zeros(vec![0, 0])));
415    }
416
417    let row_strs: Vec<&str> = inner.split(';').collect();
418    let mut values: Vec<Value> = Vec::new();
419    let mut nrows = 0usize;
420    let mut ncols: Option<usize> = None;
421
422    for row_str in &row_strs {
423        let tokens: Vec<&str> = row_str
424            .split(|c: char| c == ',' || c.is_ascii_whitespace())
425            .filter(|t| !t.is_empty())
426            .collect();
427        if tokens.is_empty() {
428            continue;
429        }
430        match ncols {
431            None => ncols = Some(tokens.len()),
432            Some(expected) if tokens.len() != expected => return None,
433            _ => {}
434        }
435        for token in &tokens {
436            values.push(parse_scalar_value(token)?);
437        }
438        nrows += 1;
439    }
440
441    let ncols = ncols.unwrap_or(0);
442    if nrows == 0 || ncols == 0 {
443        return Some(Value::Tensor(Tensor::zeros(vec![0, 0])));
444    }
445    // Scalar: preserve the exact type (Bool or Num) rather than always wrapping in Tensor.
446    if nrows == 1 && ncols == 1 {
447        return Some(values.remove(0));
448    }
449
450    // All-logical → LogicalArray; any numeric element → Tensor (bools coerced to f64).
451    // `values` is in row-major order (row 0 left-to-right, then row 1, …), but both
452    // Tensor and LogicalArray store data in column-major order (data[r + c*rows]).
453    // Reorder so that column-major index maps to the correct element.
454    let all_logical = values.iter().all(|v| matches!(v, Value::Bool(_)));
455    if all_logical {
456        let mut data: Vec<u8> = vec![0u8; nrows * ncols];
457        for r in 0..nrows {
458            for c in 0..ncols {
459                let row_major_idx = r * ncols + c;
460                let col_major_idx = r + c * nrows;
461                data[col_major_idx] = match &values[row_major_idx] {
462                    Value::Bool(b) => u8::from(*b),
463                    _ => unreachable!(),
464                };
465            }
466        }
467        LogicalArray::new(data, vec![nrows, ncols])
468            .ok()
469            .map(Value::LogicalArray)
470    } else {
471        let mut data: Vec<f64> = vec![0f64; nrows * ncols];
472        for r in 0..nrows {
473            for c in 0..ncols {
474                let row_major_idx = r * ncols + c;
475                let col_major_idx = r + c * nrows;
476                data[col_major_idx] = match &values[row_major_idx] {
477                    Value::Num(f) => *f,
478                    Value::Bool(b) => f64::from(u8::from(*b)),
479                    _ => unreachable!(),
480                };
481            }
482        }
483        Tensor::new_2d(data, nrows, ncols).ok().map(Value::Tensor)
484    }
485}
486
487#[cfg(test)]
488pub(crate) mod tests {
489    use super::*;
490    use crate::interaction::{push_queued_response, InteractionResponse};
491
492    #[test]
493    fn input_descriptor_signatures_cover_core_forms() {
494        let labels: Vec<&str> = INPUT_DESCRIPTOR
495            .signatures
496            .iter()
497            .map(|sig| sig.label)
498            .collect();
499        assert!(labels.contains(&"value = input()"));
500        assert!(labels.contains(&"value = input(prompt)"));
501        assert!(labels.contains(&"value = input(prompt, stringFlag)"));
502        assert!(labels.contains(&"value = input(stringFlag, prompt)"));
503    }
504
505    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
506    #[test]
507    fn numeric_input_parses_scalar() {
508        push_queued_response(Ok(InteractionResponse::Line("41".into())));
509        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
510        assert_eq!(value, Value::Num(41.0));
511    }
512
513    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
514    #[test]
515    fn string_mode_returns_char_row() {
516        push_queued_response(Ok(InteractionResponse::Line("RunMat".into())));
517        let prompt = Value::CharArray(CharArray::new_row("Name: "));
518        let mode = Value::String("s".to_string());
519        let value = futures::executor::block_on(input_builtin(vec![prompt, mode])).expect("input");
520        assert_eq!(value, Value::CharArray(CharArray::new_row("RunMat")));
521    }
522
523    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
524    #[test]
525    fn empty_response_returns_empty_tensor() {
526        push_queued_response(Ok(InteractionResponse::Line("   ".into())));
527        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
528        match value {
529            Value::Tensor(t) => assert!(t.data.is_empty()),
530            other => panic!("expected empty tensor, got {other:?}"),
531        }
532    }
533
534    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
535    #[test]
536    fn matrix_literal_parses_without_eval_hook() {
537        // The fast-path parser handles `[1 2 3]` directly, so no eval hook (and
538        // therefore no recursive interpret() call) is needed.
539        push_queued_response(Ok(InteractionResponse::Line("[1 2 3]".into())));
540        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
541        match value {
542            Value::Tensor(t) => {
543                assert_eq!(t.rows, 1);
544                assert_eq!(t.cols, 3);
545                assert_eq!(t.data, vec![1.0, 2.0, 3.0]);
546            }
547            other => panic!("expected 1×3 tensor, got {other:?}"),
548        }
549    }
550
551    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
552    #[test]
553    fn named_constants_parse_without_eval_hook() {
554        push_queued_response(Ok(InteractionResponse::Line("pi".into())));
555        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
556        assert_eq!(value, Value::Num(std::f64::consts::PI));
557    }
558
559    /// `e` is not a MATLAB built-in constant. The fast-path parser must not map
560    /// it to Euler's number; it should fall through so the eval hook or
561    /// `str2double` can handle it (which will NaN or error on an unknown identifier).
562    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
563    #[test]
564    fn bare_e_is_not_eulers_number() {
565        assert_eq!(parse_scalar_value("e"), None);
566        assert_eq!(parse_scalar_value("E"), None);
567    }
568
569    /// `[1 e 3]` must not silently produce `[1.0, 2.718…, 3.0]`.
570    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
571    #[test]
572    fn matrix_with_bare_e_does_not_parse() {
573        assert_eq!(parse_matrix_literal("[1 e 3]"), None);
574    }
575
576    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
577    #[test]
578    fn true_input_returns_logical_not_double() {
579        push_queued_response(Ok(InteractionResponse::Line("true".into())));
580        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
581        assert_eq!(value, Value::Bool(true));
582    }
583
584    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
585    #[test]
586    fn false_input_returns_logical_not_double() {
587        push_queued_response(Ok(InteractionResponse::Line("false".into())));
588        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
589        assert_eq!(value, Value::Bool(false));
590    }
591
592    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
593    #[test]
594    fn bool_input_is_case_insensitive() {
595        push_queued_response(Ok(InteractionResponse::Line("TRUE".into())));
596        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
597        assert_eq!(value, Value::Bool(true));
598    }
599
600    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
601    #[test]
602    fn column_vector_parses_without_eval_hook() {
603        push_queued_response(Ok(InteractionResponse::Line("[1;2;3]".into())));
604        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
605        match value {
606            Value::Tensor(t) => {
607                assert_eq!(t.rows, 3);
608                assert_eq!(t.cols, 1);
609                assert_eq!(t.data, vec![1.0, 2.0, 3.0]);
610            }
611            other => panic!("expected 3×1 tensor, got {other:?}"),
612        }
613    }
614
615    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
616    #[test]
617    fn logical_row_vector_parses_as_logical_array() {
618        push_queued_response(Ok(InteractionResponse::Line("[true false]".into())));
619        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
620        match value {
621            Value::LogicalArray(la) => {
622                assert_eq!(la.shape, vec![1, 2]);
623                assert_eq!(la.data, vec![1, 0]);
624            }
625            other => panic!("expected LogicalArray, got {other:?}"),
626        }
627    }
628
629    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
630    #[test]
631    fn logical_column_vector_parses_as_logical_array() {
632        push_queued_response(Ok(InteractionResponse::Line("[true; false]".into())));
633        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
634        match value {
635            Value::LogicalArray(la) => {
636                assert_eq!(la.shape, vec![2, 1]);
637                assert_eq!(la.data, vec![1, 0]);
638            }
639            other => panic!("expected LogicalArray, got {other:?}"),
640        }
641    }
642
643    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
644    #[test]
645    fn mixed_logical_and_numeric_coerces_to_double_tensor() {
646        push_queued_response(Ok(InteractionResponse::Line("[true 2.0]".into())));
647        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
648        match value {
649            Value::Tensor(t) => {
650                assert_eq!(t.rows, 1);
651                assert_eq!(t.cols, 2);
652                assert_eq!(t.data, vec![1.0, 2.0]);
653            }
654            other => panic!("expected Tensor, got {other:?}"),
655        }
656    }
657
658    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
659    #[test]
660    fn matrix_2x2_column_major_layout() {
661        // [1 2; 3 4] → get2(r,c) must return element at row r, col c, not the transpose.
662        // Column-major storage: data = [1, 3, 2, 4] (not the row-major [1, 2, 3, 4]).
663        push_queued_response(Ok(InteractionResponse::Line("[1 2; 3 4]".into())));
664        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
665        match value {
666            Value::Tensor(t) => {
667                assert_eq!(t.rows, 2);
668                assert_eq!(t.cols, 2);
669                assert_eq!(t.get2(0, 0).unwrap(), 1.0, "(0,0) should be 1");
670                assert_eq!(t.get2(0, 1).unwrap(), 2.0, "(0,1) should be 2");
671                assert_eq!(t.get2(1, 0).unwrap(), 3.0, "(1,0) should be 3");
672                assert_eq!(t.get2(1, 1).unwrap(), 4.0, "(1,1) should be 4");
673            }
674            other => panic!("expected 2×2 tensor, got {other:?}"),
675        }
676    }
677
678    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
679    #[test]
680    fn logical_matrix_2x2_column_major_layout() {
681        // [true false; false true] → column-major data = [1, 0, 0, 1].
682        push_queued_response(Ok(InteractionResponse::Line(
683            "[true false; false true]".into(),
684        )));
685        let value = futures::executor::block_on(input_builtin(vec![])).expect("input");
686        match value {
687            Value::LogicalArray(la) => {
688                assert_eq!(la.shape, vec![2, 2]);
689                // column-major: col 0 first ([true, false]), then col 1 ([false, true])
690                assert_eq!(la.data, vec![1, 0, 0, 1]);
691            }
692            other => panic!("expected 2×2 LogicalArray, got {other:?}"),
693        }
694    }
695
696    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
697    #[test]
698    fn invalid_string_flag_errors_before_prompt() {
699        push_queued_response(Ok(InteractionResponse::Line("ignored".into())));
700        let prompt = Value::String("Ready?".to_string());
701        let bad_flag = Value::String("not-string-mode".to_string());
702        let err = futures::executor::block_on(input_builtin(vec![prompt, bad_flag])).unwrap_err();
703        assert_eq!(err.identifier(), Some("RunMat:input:InvalidStringFlag"));
704    }
705}