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