Skip to main content

runmat_runtime/builtins/strings/transform/
extractbetween.rs

1//! MATLAB-compatible `extractBetween` builtin with GPU-aware semantics for RunMat.
2
3use std::cmp::min;
4
5use crate::builtins::common::broadcast::{broadcast_index, broadcast_shapes, compute_strides};
6use crate::builtins::common::map_control_flow_with_builtin;
7use crate::builtins::strings::common::{char_row_to_string_slice, is_missing_string};
8use crate::builtins::strings::type_resolvers::text_preserve_type;
9use crate::{
10    build_runtime_error, gather_if_needed_async, make_cell_with_shape, BuiltinResult, RuntimeError,
11};
12use runmat_builtins::{
13    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
14    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
15    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
16    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
17    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
18    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
19};
20use runmat_macros::runtime_builtin;
21use runmat_value::{CharArray, IntValue, NumericScalar, StringArray, Value};
22
23use crate::builtins::common::spec::{
24    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
25    ReductionNaN, ResidencyPolicy, ShapeRequirements,
26};
27
28#[runmat_macros::register_gpu_spec(
29    builtin_path = "crate::builtins::strings::transform::extractbetween"
30)]
31pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
32    name: "extractBetween",
33    op_kind: GpuOpKind::Custom("string-transform"),
34    supported_precisions: &[],
35    broadcast: BroadcastSemantics::Matlab,
36    provider_hooks: &[],
37    constant_strategy: ConstantStrategy::InlineLiteral,
38    residency: ResidencyPolicy::GatherImmediately,
39    nan_mode: ReductionNaN::Include,
40    two_pass_threshold: None,
41    workgroup_size: None,
42    accepts_nan_mode: false,
43    notes: "Runs on the CPU; GPU-resident inputs are gathered before extraction and outputs are returned on the host.",
44};
45
46#[runmat_macros::register_fusion_spec(
47    builtin_path = "crate::builtins::strings::transform::extractbetween"
48)]
49pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
50    name: "extractBetween",
51    shape: ShapeRequirements::Any,
52    constant_strategy: ConstantStrategy::InlineLiteral,
53    elementwise: None,
54    reduction: None,
55    emits_nan: false,
56    notes: "Pure string manipulation builtin; excluded from fusion plans and gathers GPU inputs immediately.",
57};
58
59const BUILTIN_NAME: &str = "extractBetween";
60
61const FULL_BROADCAST_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
62    id: "extractbetween-full-broadcast",
63    mode: BuiltinExtensionMode::RunMatOnly,
64    description: "extractBetween with non-scalar boundary expansion is a RunMat extension",
65    error_identifier: Some("RunMat:compatibility:ExtractBetweenFullBroadcastExtension"),
66};
67const RESIDENT_POSITION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
68    id: "extractbetween-resident-position",
69    mode: BuiltinExtensionMode::RunMatOnly,
70    description: "extractBetween with resident numeric positions is a RunMat extension",
71    error_identifier: Some("RunMat:compatibility:ExtractBetweenResidentPositionExtension"),
72};
73const CHAR_MATRIX_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
74    id: "extractbetween-char-matrix",
75    mode: BuiltinExtensionMode::RunMatOnly,
76    description: "extractBetween row-wise character-matrix input is a RunMat extension",
77    error_identifier: Some("RunMat:compatibility:ExtractBetweenCharMatrixExtension"),
78};
79const STRING_CELL_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
80    id: "extractbetween-string-cell",
81    mode: BuiltinExtensionMode::RunMatOnly,
82    description: "extractBetween cells containing string scalars are a RunMat extension",
83    error_identifier: Some("RunMat:compatibility:ExtractBetweenStringCellExtension"),
84};
85const EXTENSIONS: [BuiltinExtensionDescriptor; 4] = [
86    FULL_BROADCAST_EXTENSION,
87    RESIDENT_POSITION_EXTENSION,
88    CHAR_MATRIX_EXTENSION,
89    STRING_CELL_EXTENSION,
90];
91const INTEGER_POSITION_INPUTS: [BuiltinIntegerInputCapability; 2] = [
92    BuiltinIntegerInputCapability {
93        name: "startPos",
94        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
95        availability: BuiltinIntegerInputAvailability::Documented,
96        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
97        notes: "The public numeric position accepts every built-in integer class and is read exactly with one-based indexing.",
98    },
99    BuiltinIntegerInputCapability {
100        name: "endPos",
101        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
102        availability: BuiltinIntegerInputAvailability::Documented,
103        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
104        notes: "The public numeric position accepts every built-in integer class and is read exactly with one-based indexing.",
105    },
106];
107const INTEGER_TEXT_INPUTS: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
108    name: "str",
109    classes: &[],
110    availability: BuiltinIntegerInputAvailability::Rejected,
111    scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
112    notes: "The first argument is text; integer data rejects before provider access.",
113}];
114pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
115    BuiltinIntegerCapabilityDescriptor {
116        form: "newStr = extractBetween(str, integer_startPos, integer_endPos)",
117        inputs: &INTEGER_POSITION_INPUTS,
118        computation_domain: BuiltinIntegerComputationDomain::Structural,
119        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
120        overflow: BuiltinIntegerOverflowRule::Error,
121        backend: BuiltinIntegerBackendRule::HostOnly,
122        overload: BuiltinIntegerOverloadKind::SameSizeOrScalar,
123        notes: "Strict compatibility accepts scalar positions or arrays exactly the same size as str; string input returns string and other documented text input returns cellstr.",
124    },
125    BuiltinIntegerCapabilityDescriptor {
126        form: "newStr = extractBetween(integer_str, start, end)",
127        inputs: &INTEGER_TEXT_INPUTS,
128        computation_domain: BuiltinIntegerComputationDomain::FunctionSpecific,
129        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
130        overflow: BuiltinIntegerOverflowRule::NotApplicable,
131        backend: BuiltinIntegerBackendRule::HostOnly,
132        overload: BuiltinIntegerOverloadKind::FunctionSpecific,
133        notes: "Integer text input is outside the public domain and rejects without numeric-to-text conversion.",
134    },
135];
136
137const EXTRACT_BETWEEN_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
138    name: "newText",
139    ty: BuiltinParamType::Any,
140    arity: BuiltinParamArity::Required,
141    default: None,
142    description: "Extracted text preserving scalar/array/cell text container semantics.",
143}];
144
145const EXTRACT_BETWEEN_INPUTS_BASE: [BuiltinParamDescriptor; 3] = [
146    BuiltinParamDescriptor {
147        name: "str",
148        ty: BuiltinParamType::Any,
149        arity: BuiltinParamArity::Required,
150        default: None,
151        description: "Input text scalar/array/cell.",
152    },
153    BuiltinParamDescriptor {
154        name: "start",
155        ty: BuiltinParamType::Any,
156        arity: BuiltinParamArity::Required,
157        default: None,
158        description: "Start boundary marker text or positive integer position(s).",
159    },
160    BuiltinParamDescriptor {
161        name: "end",
162        ty: BuiltinParamType::Any,
163        arity: BuiltinParamArity::Required,
164        default: None,
165        description: "End boundary marker text or positive integer position(s).",
166    },
167];
168
169const EXTRACT_BETWEEN_INPUTS_NAME_VALUE: [BuiltinParamDescriptor; 5] = [
170    BuiltinParamDescriptor {
171        name: "str",
172        ty: BuiltinParamType::Any,
173        arity: BuiltinParamArity::Required,
174        default: None,
175        description: "Input text scalar/array/cell.",
176    },
177    BuiltinParamDescriptor {
178        name: "start",
179        ty: BuiltinParamType::Any,
180        arity: BuiltinParamArity::Required,
181        default: None,
182        description: "Start boundary marker text or positive integer position(s).",
183    },
184    BuiltinParamDescriptor {
185        name: "end",
186        ty: BuiltinParamType::Any,
187        arity: BuiltinParamArity::Required,
188        default: None,
189        description: "End boundary marker text or positive integer position(s).",
190    },
191    BuiltinParamDescriptor {
192        name: "Name",
193        ty: BuiltinParamType::StringScalar,
194        arity: BuiltinParamArity::Required,
195        default: None,
196        description: "Option name (`Boundaries`).",
197    },
198    BuiltinParamDescriptor {
199        name: "Value",
200        ty: BuiltinParamType::Any,
201        arity: BuiltinParamArity::Variadic,
202        default: None,
203        description: "Option value and additional Name/Value pairs.",
204    },
205];
206
207const EXTRACT_BETWEEN_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
208    BuiltinSignatureDescriptor {
209        label: "newText = extractBetween(str, start, end)",
210        inputs: &EXTRACT_BETWEEN_INPUTS_BASE,
211        outputs: &EXTRACT_BETWEEN_OUTPUT,
212    },
213    BuiltinSignatureDescriptor {
214        label: "newText = extractBetween(str, start, end, Name, Value, ...)",
215        inputs: &EXTRACT_BETWEEN_INPUTS_NAME_VALUE,
216        outputs: &EXTRACT_BETWEEN_OUTPUT,
217    },
218];
219
220const EXTRACT_BETWEEN_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
221    code: "RM.EXTRACT_BETWEEN.INVALID_INPUT",
222    identifier: Some("RunMat:extractBetween:InvalidInput"),
223    when: "First argument is not a string array, character array, or cell array of text scalars.",
224    message:
225        "extractBetween: first argument must be a string array, character array, or cell array of character vectors",
226};
227
228const EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
229    code: "RM.EXTRACT_BETWEEN.BOUNDARY_TYPE",
230    identifier: Some("RunMat:extractBetween:BoundaryType"),
231    when: "Start/end boundaries are mixed text/numeric domains or use unsupported boundary types.",
232    message:
233        "extractBetween: start and end arguments must both be text or both be numeric positions",
234};
235
236const EXTRACT_BETWEEN_ERROR_POSITION_TYPE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
237    code: "RM.EXTRACT_BETWEEN.POSITION_TYPE",
238    identifier: Some("RunMat:extractBetween:PositionType"),
239    when: "Numeric boundary positions are not positive finite integers.",
240    message: "extractBetween: position arguments must be positive integers",
241};
242
243const EXTRACT_BETWEEN_ERROR_NAME_VALUE_PAIR: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
244    code: "RM.EXTRACT_BETWEEN.NAME_VALUE_PAIR",
245    identifier: Some("RunMat:extractBetween:NameValuePair"),
246    when: "Name/value options are not supplied in complete pairs.",
247    message: "extractBetween: name-value arguments must appear in pairs",
248};
249
250const EXTRACT_BETWEEN_ERROR_OPTION_NAME: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
251    code: "RM.EXTRACT_BETWEEN.OPTION_NAME",
252    identifier: Some("RunMat:extractBetween:OptionName"),
253    when: "An option name other than `Boundaries` was supplied.",
254    message: "extractBetween: unrecognized parameter name",
255};
256
257const EXTRACT_BETWEEN_ERROR_OPTION_VALUE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
258    code: "RM.EXTRACT_BETWEEN.OPTION_VALUE",
259    identifier: Some("RunMat:extractBetween:OptionValue"),
260    when: "`Boundaries` option value is not `inclusive` or `exclusive`.",
261    message: "extractBetween: 'Boundaries' must be either 'inclusive' or 'exclusive'",
262};
263
264const EXTRACT_BETWEEN_ERROR_CELL_ELEMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
265    code: "RM.EXTRACT_BETWEEN.CELL_ELEMENT",
266    identifier: Some("RunMat:extractBetween:CellElement"),
267    when: "Cell text input/boundary contains non-text values or non-row char arrays.",
268    message: "extractBetween: cell array elements must be string scalars or character vectors",
269};
270
271const EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
272    code: "RM.EXTRACT_BETWEEN.SIZE_MISMATCH",
273    identifier: Some("RunMat:extractBetween:SizeMismatch"),
274    when: "Text/boundary inputs are not broadcast-compatible for extraction.",
275    message: "extractBetween: boundary sizes must be compatible with the text input",
276};
277
278const EXTRACT_BETWEEN_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
279    code: "RM.EXTRACT_BETWEEN.INTERNAL",
280    identifier: Some("RunMat:extractBetween:InternalError"),
281    when: "Internal output construction failed.",
282    message: "extractBetween: internal error",
283};
284
285const EXTRACT_BETWEEN_ERRORS: [BuiltinErrorDescriptor; 9] = [
286    EXTRACT_BETWEEN_ERROR_INVALID_INPUT,
287    EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE,
288    EXTRACT_BETWEEN_ERROR_POSITION_TYPE,
289    EXTRACT_BETWEEN_ERROR_NAME_VALUE_PAIR,
290    EXTRACT_BETWEEN_ERROR_OPTION_NAME,
291    EXTRACT_BETWEEN_ERROR_OPTION_VALUE,
292    EXTRACT_BETWEEN_ERROR_CELL_ELEMENT,
293    EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH,
294    EXTRACT_BETWEEN_ERROR_INTERNAL,
295];
296
297pub const EXTRACT_BETWEEN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
298    signatures: &EXTRACT_BETWEEN_SIGNATURES,
299    output_mode: BuiltinOutputMode::Fixed,
300    completion_policy: BuiltinCompletionPolicy::Public,
301    errors: &EXTRACT_BETWEEN_ERRORS,
302};
303
304fn extract_between_error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
305    extract_between_error_with_message(error.message, error)
306}
307
308fn extract_between_error_with_message(
309    message: impl Into<String>,
310    error: &'static BuiltinErrorDescriptor,
311) -> RuntimeError {
312    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
313    if let Some(identifier) = error.identifier {
314        builder = builder.with_identifier(identifier);
315    }
316    builder.build()
317}
318
319fn map_flow(err: RuntimeError) -> RuntimeError {
320    map_control_flow_with_builtin(err, BUILTIN_NAME)
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324enum BoundariesMode {
325    Exclusive,
326    Inclusive,
327}
328
329#[runtime_builtin(
330    name = "extractBetween",
331    category = "strings/transform",
332    summary = "Extract substrings between boundary markers.",
333    keywords = "extractBetween,substring,boundaries,strings",
334    accel = "sink",
335    extensions(EXTENSIONS),
336    integer_capabilities(INTEGER_CAPABILITIES),
337    type_resolver(text_preserve_type),
338    descriptor(crate::builtins::strings::transform::extractbetween::EXTRACT_BETWEEN_DESCRIPTOR),
339    builtin_path = "crate::builtins::strings::transform::extractbetween"
340)]
341async fn extract_between_builtin(
342    text: Value,
343    start: Value,
344    stop: Value,
345    rest: Vec<Value>,
346) -> BuiltinResult<Value> {
347    if is_numeric_or_resident(&text) || contains_nested_numeric_or_resident(&text) {
348        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_INVALID_INPUT));
349    }
350    if is_resident_or_contains_resident(&start) || is_resident_or_contains_resident(&stop) {
351        crate::compatibility::ensure_builtin_extension_enabled(
352            &RESIDENT_POSITION_EXTENSION,
353            BUILTIN_NAME,
354        )?;
355    }
356    if is_char_matrix(&text) || is_char_matrix(&start) || is_char_matrix(&stop) {
357        crate::compatibility::ensure_builtin_extension_enabled(
358            &CHAR_MATRIX_EXTENSION,
359            BUILTIN_NAME,
360        )?;
361    }
362    if cell_contains_string(&text) || cell_contains_string(&start) || cell_contains_string(&stop) {
363        crate::compatibility::ensure_builtin_extension_enabled(
364            &STRING_CELL_EXTENSION,
365            BUILTIN_NAME,
366        )?;
367    }
368    if raw_boundary_uses_full_broadcast(&text, &start)
369        || raw_boundary_uses_full_broadcast(&text, &stop)
370    {
371        crate::compatibility::ensure_builtin_extension_enabled(
372            &FULL_BROADCAST_EXTENSION,
373            BUILTIN_NAME,
374        )?;
375    }
376    if rest
377        .iter()
378        .any(|value| is_numeric_or_resident(value) || contains_nested_numeric_or_resident(value))
379    {
380        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_OPTION_VALUE));
381    }
382    let text = gather_if_needed_async(&text).await.map_err(map_flow)?;
383    let start = gather_if_needed_async(&start).await.map_err(map_flow)?;
384    let stop = gather_if_needed_async(&stop).await.map_err(map_flow)?;
385
386    let mode_override = parse_boundaries_option(&rest).await?;
387
388    let normalized_text = NormalizedText::from_value(text)?;
389    let start_boundary = BoundaryArg::from_value(start)?;
390    let stop_boundary = BoundaryArg::from_value(stop)?;
391
392    if start_boundary.kind() != stop_boundary.kind() {
393        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE));
394    }
395    let boundary_kind = start_boundary.kind();
396    let effective_mode = mode_override.unwrap_or(match boundary_kind {
397        BoundaryKind::Text => BoundariesMode::Exclusive,
398        BoundaryKind::Position => BoundariesMode::Inclusive,
399    });
400
401    let start_shape = start_boundary.shape();
402    let stop_shape = stop_boundary.shape();
403    let text_shape = normalized_text.shape();
404
405    if !shape_is_scalar_or_same(start_shape, text_shape)
406        || !shape_is_scalar_or_same(stop_shape, text_shape)
407    {
408        crate::compatibility::ensure_builtin_extension_enabled(
409            &FULL_BROADCAST_EXTENSION,
410            BUILTIN_NAME,
411        )?;
412    }
413
414    let shape_ts = broadcast_shapes(BUILTIN_NAME, text_shape, start_shape).map_err(|err| {
415        extract_between_error_with_message(
416            format!("{}: {err}", EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH.message),
417            &EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH,
418        )
419    })?;
420    let output_shape = broadcast_shapes(BUILTIN_NAME, &shape_ts, stop_shape).map_err(|err| {
421        extract_between_error_with_message(
422            format!("{}: {err}", EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH.message),
423            &EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH,
424        )
425    })?;
426    if !normalized_text.supports_shape(&output_shape) {
427        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH));
428    }
429
430    let total: usize = output_shape.iter().copied().product();
431    if total == 0 {
432        return normalized_text.into_value(Vec::new(), output_shape);
433    }
434
435    let text_strides = compute_strides(text_shape);
436    let start_strides = compute_strides(start_shape);
437    let stop_strides = compute_strides(stop_shape);
438
439    let mut results = Vec::with_capacity(total);
440
441    for idx in 0..total {
442        let text_idx = broadcast_index(idx, &output_shape, text_shape, &text_strides);
443        let start_idx = broadcast_index(idx, &output_shape, start_shape, &start_strides);
444        let stop_idx = broadcast_index(idx, &output_shape, stop_shape, &stop_strides);
445
446        let result = match boundary_kind {
447            BoundaryKind::Text => {
448                let text_value = normalized_text.data(text_idx);
449                let start_value = start_boundary.text(start_idx);
450                let stop_value = stop_boundary.text(stop_idx);
451                extract_with_text_boundaries(text_value, start_value, stop_value, effective_mode)
452            }
453            BoundaryKind::Position => {
454                let text_value = normalized_text.data(text_idx);
455                let start_value = start_boundary.position(start_idx);
456                let stop_value = stop_boundary.position(stop_idx);
457                extract_with_positions(text_value, start_value, stop_value, effective_mode)
458            }
459        };
460        results.push(result);
461    }
462
463    normalized_text.into_value(results, output_shape)
464}
465
466fn is_numeric_or_resident(value: &Value) -> bool {
467    matches!(
468        value,
469        Value::Num(_)
470            | Value::Int(_)
471            | Value::Bool(_)
472            | Value::Tensor(_)
473            | Value::LogicalArray(_)
474            | Value::Complex(_, _)
475            | Value::ComplexTensor(_)
476            | Value::GpuTensor(_)
477    )
478}
479
480fn contains_nested_numeric_or_resident(value: &Value) -> bool {
481    match value {
482        Value::Cell(cell) => cell.data.iter().any(|value| {
483            is_numeric_or_resident(value) || contains_nested_numeric_or_resident(value)
484        }),
485        _ => false,
486    }
487}
488
489fn is_resident_or_contains_resident(value: &Value) -> bool {
490    match value {
491        Value::GpuTensor(_) => true,
492        Value::Cell(cell) => cell.data.iter().any(is_resident_or_contains_resident),
493        _ => false,
494    }
495}
496
497fn is_char_matrix(value: &Value) -> bool {
498    matches!(value, Value::CharArray(array) if array.rows > 1)
499}
500
501fn cell_contains_string(value: &Value) -> bool {
502    match value {
503        Value::Cell(cell) => cell.data.iter().any(|value| {
504            matches!(value, Value::String(_) | Value::StringArray(_)) || cell_contains_string(value)
505        }),
506        _ => false,
507    }
508}
509
510fn raw_text_shape(value: &Value) -> Option<Vec<usize>> {
511    match value {
512        Value::String(_) => Some(vec![1, 1]),
513        Value::StringArray(array) => Some(array.shape.clone()),
514        Value::CharArray(array) if array.rows <= 1 => Some(vec![1, 1]),
515        Value::CharArray(array) => Some(vec![array.rows, 1]),
516        Value::Cell(cell) => Some(cell.shape.clone()),
517        _ => None,
518    }
519}
520
521fn raw_boundary_shape(value: &Value) -> Option<Vec<usize>> {
522    match value {
523        Value::String(_) | Value::Num(_) | Value::Int(_) | Value::Object(_) => Some(vec![1, 1]),
524        Value::StringArray(array) => Some(array.shape.clone()),
525        Value::CharArray(array) if array.rows <= 1 => Some(vec![1, 1]),
526        Value::CharArray(array) => Some(vec![array.rows, 1]),
527        Value::Cell(cell) => Some(cell.shape.clone()),
528        Value::Tensor(tensor) => Some(tensor.shape.clone()),
529        Value::GpuTensor(handle) => Some(handle.shape.clone()),
530        _ => None,
531    }
532}
533
534fn raw_boundary_uses_full_broadcast(text: &Value, boundary: &Value) -> bool {
535    let (Some(text_shape), Some(boundary_shape)) =
536        (raw_text_shape(text), raw_boundary_shape(boundary))
537    else {
538        return false;
539    };
540    !shape_is_scalar_or_same(&boundary_shape, &text_shape)
541}
542
543fn shape_is_scalar_or_same(shape: &[usize], text_shape: &[usize]) -> bool {
544    shape
545        .iter()
546        .try_fold(1usize, |acc, dim| acc.checked_mul(*dim))
547        == Some(1)
548        || shape == text_shape
549}
550
551async fn parse_boundaries_option(args: &[Value]) -> BuiltinResult<Option<BoundariesMode>> {
552    if args.is_empty() {
553        return Ok(None);
554    }
555    if !args.len().is_multiple_of(2) {
556        return Err(extract_between_error(
557            &EXTRACT_BETWEEN_ERROR_NAME_VALUE_PAIR,
558        ));
559    }
560
561    let mut mode: Option<BoundariesMode> = None;
562    let mut idx = 0;
563    while idx < args.len() {
564        let name_value = gather_if_needed_async(&args[idx]).await.map_err(map_flow)?;
565        let name = value_to_string(&name_value)
566            .ok_or_else(|| extract_between_error(&EXTRACT_BETWEEN_ERROR_OPTION_NAME))?;
567        if !name.eq_ignore_ascii_case("boundaries") {
568            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_OPTION_NAME));
569        }
570        let value = gather_if_needed_async(&args[idx + 1])
571            .await
572            .map_err(map_flow)?;
573        let value_str = value_to_string(&value)
574            .ok_or_else(|| extract_between_error(&EXTRACT_BETWEEN_ERROR_OPTION_VALUE))?;
575        let parsed_mode = if value_str.eq_ignore_ascii_case("inclusive") {
576            BoundariesMode::Inclusive
577        } else if value_str.eq_ignore_ascii_case("exclusive") {
578            BoundariesMode::Exclusive
579        } else {
580            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_OPTION_VALUE));
581        };
582        mode = Some(parsed_mode);
583        idx += 2;
584    }
585    Ok(mode)
586}
587
588fn value_to_string(value: &Value) -> Option<String> {
589    match value {
590        Value::String(s) => Some(s.clone()),
591        Value::StringArray(sa) if sa.data.len() == 1 => Some(sa.data[0].clone()),
592        Value::CharArray(ca) if ca.rows <= 1 => {
593            if ca.rows == 0 {
594                Some(String::new())
595            } else {
596                Some(char_row_to_string_slice(&ca.data, ca.cols, 0))
597            }
598        }
599        Value::CharArray(_) => None,
600        Value::Cell(cell) if cell.data.len() == 1 => {
601            let element = &cell.data[0];
602            value_to_string(element)
603        }
604        _ => None,
605    }
606}
607
608#[derive(Clone)]
609struct ExtractResult {
610    text: String,
611}
612
613impl ExtractResult {
614    fn missing() -> Self {
615        Self {
616            text: "<missing>".to_string(),
617        }
618    }
619
620    fn text(text: String) -> Self {
621        Self { text }
622    }
623}
624
625fn extract_with_text_boundaries(
626    text: &str,
627    start: &str,
628    stop: &str,
629    mode: BoundariesMode,
630) -> ExtractResult {
631    if is_missing_string(text) || is_missing_string(start) || is_missing_string(stop) {
632        return ExtractResult::missing();
633    }
634
635    if let Some(start_idx) = text.find(start) {
636        let search_start = start_idx + start.len();
637        if search_start > text.len() {
638            return ExtractResult::text(String::new());
639        }
640        if let Some(relative_end) = text[search_start..].find(stop) {
641            let end_idx = search_start + relative_end;
642            match mode {
643                BoundariesMode::Inclusive => {
644                    let end_capture = min(text.len(), end_idx + stop.len());
645                    let slice = &text[start_idx..end_capture];
646                    ExtractResult::text(slice.to_string())
647                }
648                BoundariesMode::Exclusive => {
649                    if end_idx < search_start {
650                        ExtractResult::text(String::new())
651                    } else {
652                        let slice = &text[search_start..end_idx];
653                        ExtractResult::text(slice.to_string())
654                    }
655                }
656            }
657        } else {
658            ExtractResult::text(String::new())
659        }
660    } else {
661        ExtractResult::text(String::new())
662    }
663}
664
665fn extract_with_positions(
666    text: &str,
667    start: usize,
668    stop: usize,
669    mode: BoundariesMode,
670) -> ExtractResult {
671    if is_missing_string(text) {
672        return ExtractResult::missing();
673    }
674    if text.is_empty() {
675        return ExtractResult::text(String::new());
676    }
677    let chars: Vec<char> = text.chars().collect();
678    let len = chars.len();
679    if len == 0 {
680        return ExtractResult::text(String::new());
681    }
682
683    if start == 0 || stop == 0 {
684        return ExtractResult::text(String::new());
685    }
686
687    if start > len {
688        return ExtractResult::text(String::new());
689    }
690    let stop_clamped = stop.min(len);
691    if stop_clamped == 0 {
692        return ExtractResult::text(String::new());
693    }
694
695    match mode {
696        BoundariesMode::Inclusive => {
697            if start > stop_clamped {
698                return ExtractResult::text(String::new());
699            }
700            let start_idx = start - 1;
701            let end_idx = stop_clamped - 1;
702            if start_idx >= len || end_idx >= len || start_idx > end_idx {
703                ExtractResult::text(String::new())
704            } else {
705                let slice: String = chars[start_idx..=end_idx].iter().collect();
706                ExtractResult::text(slice)
707            }
708        }
709        BoundariesMode::Exclusive => {
710            if start + 1 >= stop_clamped {
711                return ExtractResult::text(String::new());
712            }
713            let start_idx = start;
714            let end_idx = stop_clamped - 2;
715            if start_idx >= len || end_idx >= len || start_idx > end_idx {
716                ExtractResult::text(String::new())
717            } else {
718                let slice: String = chars[start_idx..=end_idx].iter().collect();
719                ExtractResult::text(slice)
720            }
721        }
722    }
723}
724
725#[derive(Clone, Debug)]
726struct CellInfo {
727    shape: Vec<usize>,
728    element_kinds: Vec<CellElementKind>,
729}
730
731#[derive(Clone, Debug)]
732enum CellElementKind {
733    String,
734    Char,
735}
736
737#[derive(Clone, Debug)]
738enum TextKind {
739    StringScalar,
740    StringArray,
741    CharArray { rows: usize },
742    CellArray(CellInfo),
743}
744
745#[derive(Clone, Debug)]
746struct NormalizedText {
747    data: Vec<String>,
748    shape: Vec<usize>,
749    kind: TextKind,
750}
751
752impl NormalizedText {
753    fn from_value(value: Value) -> BuiltinResult<Self> {
754        match value {
755            Value::String(s) => Ok(Self {
756                data: vec![s],
757                shape: vec![1, 1],
758                kind: TextKind::StringScalar,
759            }),
760            Value::StringArray(sa) => Ok(Self {
761                data: sa.data.clone(),
762                shape: sa.shape.clone(),
763                kind: TextKind::StringArray,
764            }),
765            Value::CharArray(ca) => {
766                let rows = ca.rows.max(1);
767                let mut data = Vec::with_capacity(rows);
768                if ca.rows == 0 {
769                    data.push(String::new());
770                } else {
771                    for row in 0..ca.rows {
772                        data.push(char_row_to_string_slice(&ca.data, ca.cols, row));
773                    }
774                }
775                Ok(Self {
776                    data,
777                    shape: if ca.rows <= 1 {
778                        vec![1, 1]
779                    } else {
780                        vec![rows, 1]
781                    },
782                    kind: TextKind::CharArray { rows },
783                })
784            }
785            Value::Cell(cell) => {
786                let shape = cell.shape.clone();
787                let mut data = Vec::with_capacity(cell.data.len());
788                let mut kinds = Vec::with_capacity(cell.data.len());
789                for element in &cell.data {
790                    match &element {
791                        Value::String(s) => {
792                            data.push(s.clone());
793                            kinds.push(CellElementKind::String);
794                        }
795                        Value::StringArray(sa) if sa.data.len() == 1 => {
796                            data.push(sa.data[0].clone());
797                            kinds.push(CellElementKind::String);
798                        }
799                        Value::CharArray(ca) if ca.rows <= 1 => {
800                            if ca.rows == 0 {
801                                data.push(String::new());
802                            } else {
803                                data.push(char_row_to_string_slice(&ca.data, ca.cols, 0));
804                            }
805                            kinds.push(CellElementKind::Char);
806                        }
807                        Value::CharArray(_) => {
808                            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_CELL_ELEMENT))
809                        }
810                        _ => {
811                            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_CELL_ELEMENT))
812                        }
813                    }
814                }
815                Ok(Self {
816                    data,
817                    shape: shape.clone(),
818                    kind: TextKind::CellArray(CellInfo {
819                        shape,
820                        element_kinds: kinds,
821                    }),
822                })
823            }
824            _ => Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_INVALID_INPUT)),
825        }
826    }
827
828    fn shape(&self) -> &[usize] {
829        &self.shape
830    }
831
832    fn data(&self, idx: usize) -> &str {
833        &self.data[idx]
834    }
835
836    fn supports_shape(&self, output_shape: &[usize]) -> bool {
837        match &self.kind {
838            TextKind::StringScalar => true,
839            TextKind::StringArray => true,
840            TextKind::CharArray { .. } => output_shape == self.shape,
841            TextKind::CellArray(info) => output_shape == info.shape,
842        }
843    }
844
845    fn into_value(
846        self,
847        results: Vec<ExtractResult>,
848        output_shape: Vec<usize>,
849    ) -> BuiltinResult<Value> {
850        match self.kind {
851            TextKind::StringScalar => {
852                if results.len() <= 1 {
853                    let value = results
854                        .into_iter()
855                        .next()
856                        .unwrap_or_else(|| ExtractResult::text(String::new()));
857                    Ok(Value::String(value.text))
858                } else {
859                    let data = results.into_iter().map(|r| r.text).collect::<Vec<_>>();
860                    let array = StringArray::new(data, output_shape).map_err(|e| {
861                        extract_between_error_with_message(
862                            format!("{BUILTIN_NAME}: {e}"),
863                            &EXTRACT_BETWEEN_ERROR_INTERNAL,
864                        )
865                    })?;
866                    Ok(Value::StringArray(array))
867                }
868            }
869            TextKind::StringArray => {
870                let data = results.into_iter().map(|r| r.text).collect::<Vec<_>>();
871                let array = StringArray::new(data, output_shape).map_err(|e| {
872                    extract_between_error_with_message(
873                        format!("{BUILTIN_NAME}: {e}"),
874                        &EXTRACT_BETWEEN_ERROR_INTERNAL,
875                    )
876                })?;
877                Ok(Value::StringArray(array))
878            }
879            TextKind::CharArray { rows } => {
880                if results.len() != rows {
881                    return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH));
882                }
883                if rows == 1 {
884                    let text = results
885                        .into_iter()
886                        .next()
887                        .unwrap_or_else(|| ExtractResult::text(String::new()))
888                        .text;
889                    return make_cell_with_shape(
890                        vec![Value::CharArray(CharArray::new_row(&text))],
891                        vec![1, 1],
892                    )
893                    .map_err(|e| {
894                        extract_between_error_with_message(
895                            format!("{BUILTIN_NAME}: {e}"),
896                            &EXTRACT_BETWEEN_ERROR_INTERNAL,
897                        )
898                    });
899                }
900                let mut max_width = 0usize;
901                let mut row_strings = Vec::with_capacity(rows);
902                for result in &results {
903                    let width = result.text.chars().count();
904                    max_width = max_width.max(width);
905                    row_strings.push(result.text.clone());
906                }
907                let mut flattened = Vec::with_capacity(rows * max_width);
908                for row in row_strings {
909                    let mut chars: Vec<char> = row.chars().collect();
910                    if chars.len() < max_width {
911                        chars.resize(max_width, ' ');
912                    }
913                    flattened.extend(chars);
914                }
915                CharArray::new(flattened, rows, max_width)
916                    .map(Value::CharArray)
917                    .map_err(|e| {
918                        extract_between_error_with_message(
919                            format!("{BUILTIN_NAME}: {e}"),
920                            &EXTRACT_BETWEEN_ERROR_INTERNAL,
921                        )
922                    })
923            }
924            TextKind::CellArray(info) => {
925                if results.len() != info.element_kinds.len() {
926                    return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_SIZE_MISMATCH));
927                }
928                let mut values = Vec::with_capacity(results.len());
929                for (idx, result) in results.into_iter().enumerate() {
930                    match info.element_kinds[idx] {
931                        CellElementKind::String => values.push(Value::String(result.text)),
932                        CellElementKind::Char => {
933                            let ca = CharArray::new_row(&result.text);
934                            values.push(Value::CharArray(ca));
935                        }
936                    }
937                }
938                make_cell_with_shape(values, info.shape).map_err(|e| {
939                    extract_between_error_with_message(
940                        format!("{BUILTIN_NAME}: {e}"),
941                        &EXTRACT_BETWEEN_ERROR_INTERNAL,
942                    )
943                })
944            }
945        }
946    }
947}
948
949#[derive(Clone, Debug, PartialEq, Eq)]
950enum BoundaryKind {
951    Text,
952    Position,
953}
954
955#[derive(Clone, Debug)]
956enum BoundaryArg {
957    Text(BoundaryText),
958    Position(BoundaryPositions),
959}
960
961impl BoundaryArg {
962    fn from_value(value: Value) -> BuiltinResult<Self> {
963        match value {
964            Value::String(_) | Value::StringArray(_) | Value::CharArray(_) | Value::Cell(_) => {
965                BoundaryText::from_value(value).map(BoundaryArg::Text)
966            }
967            Value::Num(_) | Value::Int(_) | Value::Tensor(_) => {
968                BoundaryPositions::from_value(value).map(BoundaryArg::Position)
969            }
970            other => Err(extract_between_error_with_message(
971                format!(
972                    "{}: unsupported argument {other:?}",
973                    EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE.message
974                ),
975                &EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE,
976            )),
977        }
978    }
979
980    fn kind(&self) -> BoundaryKind {
981        match self {
982            BoundaryArg::Text(_) => BoundaryKind::Text,
983            BoundaryArg::Position(_) => BoundaryKind::Position,
984        }
985    }
986
987    fn shape(&self) -> &[usize] {
988        match self {
989            BoundaryArg::Text(text) => &text.shape,
990            BoundaryArg::Position(pos) => &pos.shape,
991        }
992    }
993
994    fn text(&self, idx: usize) -> &str {
995        match self {
996            BoundaryArg::Text(text) => &text.data[idx],
997            BoundaryArg::Position(_) => unreachable!(),
998        }
999    }
1000
1001    fn position(&self, idx: usize) -> usize {
1002        match self {
1003            BoundaryArg::Position(pos) => pos.data[idx],
1004            BoundaryArg::Text(_) => unreachable!(),
1005        }
1006    }
1007}
1008
1009#[derive(Clone, Debug)]
1010struct BoundaryText {
1011    data: Vec<String>,
1012    shape: Vec<usize>,
1013}
1014
1015impl BoundaryText {
1016    fn from_value(value: Value) -> BuiltinResult<Self> {
1017        match value {
1018            Value::String(s) => Ok(Self {
1019                data: vec![s],
1020                shape: vec![1, 1],
1021            }),
1022            Value::StringArray(sa) => Ok(Self {
1023                data: sa.data.clone(),
1024                shape: sa.shape.clone(),
1025            }),
1026            Value::CharArray(ca) => {
1027                let mut data = Vec::with_capacity(ca.rows);
1028                for row in 0..ca.rows {
1029                    data.push(char_row_to_string_slice(&ca.data, ca.cols, row));
1030                }
1031                Ok(Self {
1032                    data,
1033                    shape: vec![ca.rows, 1],
1034                })
1035            }
1036            Value::Cell(cell) => {
1037                let shape = cell.shape.clone();
1038                let mut data = Vec::with_capacity(cell.data.len());
1039                for element in &cell.data {
1040                    match &element {
1041                        Value::String(s) => data.push(s.clone()),
1042                        Value::StringArray(sa) if sa.data.len() == 1 => {
1043                            data.push(sa.data[0].clone());
1044                        }
1045                        Value::CharArray(ca) if ca.rows <= 1 => {
1046                            if ca.rows == 0 {
1047                                data.push(String::new());
1048                            } else {
1049                                data.push(char_row_to_string_slice(&ca.data, ca.cols, 0));
1050                            }
1051                        }
1052                        Value::CharArray(_) => {
1053                            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_CELL_ELEMENT))
1054                        }
1055                        _ => {
1056                            return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_CELL_ELEMENT))
1057                        }
1058                    }
1059                }
1060                Ok(Self { data, shape })
1061            }
1062            _ => Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE)),
1063        }
1064    }
1065}
1066
1067#[derive(Clone, Debug)]
1068struct BoundaryPositions {
1069    data: Vec<usize>,
1070    shape: Vec<usize>,
1071}
1072
1073impl BoundaryPositions {
1074    fn from_value(value: Value) -> BuiltinResult<Self> {
1075        match value {
1076            Value::Num(n) => Ok(Self {
1077                data: vec![parse_position(n)?],
1078                shape: vec![1, 1],
1079            }),
1080            Value::Int(i) => Ok(Self {
1081                data: vec![parse_position_int(i)?],
1082                shape: vec![1, 1],
1083            }),
1084            Value::Tensor(t) => {
1085                let mut data = Vec::with_capacity(t.len());
1086                for idx in 0..t.len() {
1087                    let entry = t.numeric_value_at(idx).ok_or_else(|| {
1088                        extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE)
1089                    })?;
1090                    data.push(match entry {
1091                        NumericScalar::F64(value) => parse_position(value)?,
1092                        NumericScalar::F32(value) => parse_position(f64::from(value))?,
1093                        value => parse_position_int(
1094                            value
1095                                .into_int_value()
1096                                .expect("non-floating numeric scalar is integer"),
1097                        )?,
1098                    });
1099                }
1100                Ok(Self {
1101                    data,
1102                    shape: if t.shape.is_empty() {
1103                        vec![t.rows, t.cols.max(1)]
1104                    } else {
1105                        t.shape
1106                    },
1107                })
1108            }
1109            _ => Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE)),
1110        }
1111    }
1112}
1113
1114fn parse_position(value: f64) -> BuiltinResult<usize> {
1115    if !value.is_finite() || value < 1.0 {
1116        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE));
1117    }
1118    if (value.fract()).abs() > f64::EPSILON {
1119        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE));
1120    }
1121    if value > usize::MAX.saturating_sub(1) as f64 {
1122        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE));
1123    }
1124    let parsed = value as usize;
1125    if parsed as f64 != value || parsed == usize::MAX {
1126        return Err(extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE));
1127    }
1128    Ok(parsed)
1129}
1130
1131fn parse_position_int(value: IntValue) -> BuiltinResult<usize> {
1132    value
1133        .try_to_usize()
1134        .filter(|position| *position > 0)
1135        .ok_or_else(|| extract_between_error(&EXTRACT_BETWEEN_ERROR_POSITION_TYPE))
1136}
1137
1138#[cfg(test)]
1139pub(crate) mod tests {
1140    #![allow(non_snake_case)]
1141
1142    use super::*;
1143    use runmat_builtins::{ResolveContext, Type};
1144    use runmat_value::{CellArray, IntegerStorage, NumericStorage, Tensor};
1145
1146    fn extract_between_builtin(
1147        text: Value,
1148        start: Value,
1149        stop: Value,
1150        rest: Vec<Value>,
1151    ) -> BuiltinResult<Value> {
1152        futures::executor::block_on(super::extract_between_builtin(text, start, stop, rest))
1153    }
1154
1155    #[test]
1156    fn extractBetween_position_vectors_read_typed_integer_storage_exactly() {
1157        for storage in [
1158            IntegerStorage::I8(vec![2, 4]),
1159            IntegerStorage::I16(vec![2, 4]),
1160            IntegerStorage::I32(vec![2, 4]),
1161            IntegerStorage::I64(vec![2, 4]),
1162            IntegerStorage::U8(vec![2, 4]),
1163            IntegerStorage::U16(vec![2, 4]),
1164            IntegerStorage::U32(vec![2, 4]),
1165            IntegerStorage::U64(vec![2, 4]),
1166        ] {
1167            let positions = Tensor::new_integer(storage, vec![1, 2]).expect("positions");
1168            let parsed = BoundaryPositions::from_value(Value::Tensor(positions)).unwrap();
1169            assert_eq!(parsed.data, vec![2, 4]);
1170            assert_eq!(parsed.shape, vec![1, 2]);
1171        }
1172    }
1173
1174    #[test]
1175    fn extractBetween_position_vectors_read_native_single_storage() {
1176        let positions =
1177            Tensor::from_numeric_storage(NumericStorage::F32(vec![2.0, 4.0]), vec![1, 2])
1178                .expect("positions");
1179        let parsed = BoundaryPositions::from_value(Value::Tensor(positions)).unwrap();
1180        assert_eq!(parsed.data, vec![2, 4]);
1181    }
1182
1183    #[test]
1184    fn extractBetween_position_vectors_reject_nonpositive_integer_storage() {
1185        let positions =
1186            Tensor::new_integer(IntegerStorage::I16(vec![1, 0]), vec![1, 2]).expect("positions");
1187
1188        assert!(BoundaryPositions::from_value(Value::Tensor(positions)).is_err());
1189    }
1190
1191    #[test]
1192    fn extractBetween_position_vectors_reject_oversized_double_positions() {
1193        assert!(BoundaryPositions::from_value(Value::Num(1.0e300)).is_err());
1194    }
1195
1196    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1197    #[test]
1198    fn extractBetween_basic_string() {
1199        let result = extract_between_builtin(
1200            Value::String("RunMat accelerates MATLAB".into()),
1201            Value::String("RunMat ".into()),
1202            Value::String(" MATLAB".into()),
1203            Vec::new(),
1204        )
1205        .expect("extractBetween");
1206        assert_eq!(result, Value::String("accelerates".into()));
1207    }
1208
1209    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1210    #[test]
1211    fn extractBetween_inclusive_option() {
1212        let result = extract_between_builtin(
1213            Value::String("a[b]c".into()),
1214            Value::String("[".into()),
1215            Value::String("]".into()),
1216            vec![
1217                Value::String("Boundaries".into()),
1218                Value::String("inclusive".into()),
1219            ],
1220        )
1221        .expect("extractBetween");
1222        assert_eq!(result, Value::String("[b]".into()));
1223    }
1224
1225    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1226    #[test]
1227    fn extractBetween_numeric_positions() {
1228        let result = extract_between_builtin(
1229            Value::String("Accelerator".into()),
1230            Value::Num(3.0),
1231            Value::Num(7.0),
1232            Vec::new(),
1233        )
1234        .expect("extractBetween");
1235        assert_eq!(result, Value::String("celer".into()));
1236    }
1237
1238    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1239    #[test]
1240    fn extractBetween_numeric_positions_exclusive_option() {
1241        let result = extract_between_builtin(
1242            Value::String("Accelerator".into()),
1243            Value::Num(3.0),
1244            Value::Num(7.0),
1245            vec![
1246                Value::String("Boundaries".into()),
1247                Value::String("exclusive".into()),
1248            ],
1249        )
1250        .expect("extractBetween");
1251        assert_eq!(result, Value::String("ele".into()));
1252    }
1253
1254    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1255    #[test]
1256    fn extractBetween_numeric_positions_clamps_stop() {
1257        let result = extract_between_builtin(
1258            Value::String("Accelerator".into()),
1259            Value::Num(3.0),
1260            Value::Num(100.0),
1261            Vec::new(),
1262        )
1263        .expect("extractBetween");
1264        assert_eq!(result, Value::String("celerator".into()));
1265    }
1266
1267    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1268    #[test]
1269    fn extractBetween_numeric_positions_start_past_length() {
1270        let result = extract_between_builtin(
1271            Value::String("abc".into()),
1272            Value::Num(10.0),
1273            Value::Num(12.0),
1274            Vec::new(),
1275        )
1276        .expect("extractBetween");
1277        assert_eq!(result, Value::String(String::new()));
1278    }
1279
1280    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1281    #[test]
1282    fn extractBetween_string_array_broadcast() {
1283        let array = StringArray::new(
1284            vec!["runmat_accel.rs".into(), "runmat_gc.rs".into()],
1285            vec![2, 1],
1286        )
1287        .unwrap();
1288        let result = extract_between_builtin(
1289            Value::StringArray(array),
1290            Value::String("runmat_".into()),
1291            Value::String(".rs".into()),
1292            Vec::new(),
1293        )
1294        .expect("extractBetween");
1295        match result {
1296            Value::StringArray(sa) => {
1297                assert_eq!(sa.data, vec!["accel".to_string(), "gc".to_string()]);
1298                assert_eq!(sa.shape, vec![2, 1]);
1299            }
1300            other => panic!("expected string array, got {other:?}"),
1301        }
1302    }
1303
1304    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1305    #[test]
1306    fn extractBetween_character_vector_returns_scalar_cellstr() {
1307        let chars = CharArray::new(
1308            "GPUAccelerateVM".chars().collect(),
1309            1,
1310            "GPUAccelerateVM".len(),
1311        )
1312        .unwrap();
1313        let result = extract_between_builtin(
1314            Value::CharArray(chars),
1315            Value::String("GPU".into()),
1316            Value::String("VM".into()),
1317            Vec::new(),
1318        )
1319        .expect("extractBetween");
1320        match result {
1321            Value::Cell(out) => {
1322                assert_eq!(out.shape, vec![1, 1]);
1323                let Value::CharArray(chars) = &out.data[0] else {
1324                    panic!("expected character vector element")
1325                };
1326                let text: String = chars.data.iter().collect();
1327                assert_eq!(text.trim_end(), "Accelerate");
1328            }
1329            other => panic!("expected scalar cellstr, got {other:?}"),
1330        }
1331    }
1332
1333    #[test]
1334    fn extractBetween_integer_capabilities_and_strict_shape_are_declared() {
1335        assert_eq!(INTEGER_CAPABILITIES.len(), 2);
1336        assert_eq!(INTEGER_POSITION_INPUTS[0].classes.len(), 8);
1337        let text = Value::StringArray(
1338            StringArray::new(vec!["abcd".into(), "wxyz".into()], vec![2, 1]).unwrap(),
1339        );
1340        let start = Value::Tensor(
1341            Tensor::new_integer(IntegerStorage::U64(vec![1, 2]), vec![1, 2]).unwrap(),
1342        );
1343        let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1344        let error = extract_between_builtin(text, start, Value::Num(3.0), Vec::new())
1345            .expect_err("non-same-size expansion must be gated");
1346        assert_eq!(
1347            error.identifier(),
1348            FULL_BROADCAST_EXTENSION.error_identifier
1349        );
1350    }
1351
1352    #[test]
1353    fn extractBetween_strict_mode_gates_resident_positions_before_gather() {
1354        let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1355        let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
1356            shape: vec![1, 1],
1357            device_id: u32::MAX,
1358            buffer_id: u64::MAX,
1359            descriptor: Default::default(),
1360        });
1361        let error = extract_between_builtin(
1362            Value::String("abcd".into()),
1363            resident,
1364            Value::Num(3.0),
1365            Vec::new(),
1366        )
1367        .expect_err("resident position gate");
1368        assert_eq!(
1369            error.identifier(),
1370            RESIDENT_POSITION_EXTENSION.error_identifier
1371        );
1372    }
1373
1374    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1375    #[test]
1376    fn extractBetween_cell_array_preserves_types() {
1377        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1378        let cell = CellArray::new(
1379            vec![
1380                Value::CharArray(CharArray::new_row("A[B]C")),
1381                Value::String("Planner<GPU>".into()),
1382            ],
1383            1,
1384            2,
1385        )
1386        .unwrap();
1387        let result = extract_between_builtin(
1388            Value::Cell(cell),
1389            Value::String("[".into()),
1390            Value::String("]".into()),
1391            Vec::new(),
1392        )
1393        .expect("extractBetween");
1394        match result {
1395            Value::Cell(out) => {
1396                let first = out.get(0, 0).unwrap();
1397                let second = out.get(0, 1).unwrap();
1398                assert_eq!(first, Value::CharArray(CharArray::new_row("B")));
1399                assert_eq!(second, Value::String(String::new()));
1400            }
1401            other => panic!("expected cell array, got {other:?}"),
1402        }
1403    }
1404
1405    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1406    #[test]
1407    fn extractBetween_missing_string_propagates() {
1408        let strings = StringArray::new(vec!["<missing>".into()], vec![1, 1]).unwrap();
1409        let result = extract_between_builtin(
1410            Value::StringArray(strings),
1411            Value::String("[".into()),
1412            Value::String("]".into()),
1413            Vec::new(),
1414        )
1415        .expect("extractBetween");
1416        assert_eq!(
1417            result,
1418            Value::StringArray(StringArray::new(vec!["<missing>".into()], vec![1, 1]).unwrap())
1419        );
1420    }
1421
1422    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1423    #[test]
1424    fn extractBetween_position_type_error() {
1425        let err = extract_between_builtin(
1426            Value::String("abc".into()),
1427            Value::Num(0.5),
1428            Value::Num(2.0),
1429            Vec::new(),
1430        )
1431        .unwrap_err();
1432        assert_eq!(err.to_string(), EXTRACT_BETWEEN_ERROR_POSITION_TYPE.message);
1433        assert_eq!(
1434            err.identifier(),
1435            EXTRACT_BETWEEN_ERROR_POSITION_TYPE.identifier
1436        );
1437    }
1438
1439    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1440    #[test]
1441    fn extractBetween_mixed_boundary_error() {
1442        let err = extract_between_builtin(
1443            Value::String("abc".into()),
1444            Value::String("a".into()),
1445            Value::Num(3.0),
1446            Vec::new(),
1447        )
1448        .unwrap_err();
1449        assert_eq!(err.to_string(), EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE.message);
1450        assert_eq!(
1451            err.identifier(),
1452            EXTRACT_BETWEEN_ERROR_BOUNDARY_TYPE.identifier
1453        );
1454    }
1455
1456    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1457    #[test]
1458    fn extractBetween_numeric_tensor_broadcast() {
1459        let text = StringArray::new(vec!["abcd".into(), "wxyz".into()], vec![2, 1]).unwrap();
1460        let start = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1461        let stop = Tensor::new(vec![3.0, 4.0], vec![2, 1]).unwrap();
1462        let result = extract_between_builtin(
1463            Value::StringArray(text),
1464            Value::Tensor(start),
1465            Value::Tensor(stop),
1466            Vec::new(),
1467        )
1468        .expect("extractBetween");
1469        match result {
1470            Value::StringArray(sa) => {
1471                assert_eq!(sa.data, vec!["abc".to_string(), "xyz".to_string()]);
1472                assert_eq!(sa.shape, vec![2, 1]);
1473            }
1474            other => panic!("expected string array, got {other:?}"),
1475        }
1476    }
1477
1478    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1479    #[test]
1480    fn extractBetween_option_invalid_value() {
1481        let err = extract_between_builtin(
1482            Value::String("abc".into()),
1483            Value::String("a".into()),
1484            Value::String("c".into()),
1485            vec![
1486                Value::String("Boundaries".into()),
1487                Value::String("middle".into()),
1488            ],
1489        )
1490        .unwrap_err();
1491        assert_eq!(err.to_string(), EXTRACT_BETWEEN_ERROR_OPTION_VALUE.message);
1492        assert_eq!(
1493            err.identifier(),
1494            EXTRACT_BETWEEN_ERROR_OPTION_VALUE.identifier
1495        );
1496    }
1497
1498    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1499    #[test]
1500    fn extractBetween_option_name_error() {
1501        let err = extract_between_builtin(
1502            Value::String("abc".into()),
1503            Value::String("a".into()),
1504            Value::String("c".into()),
1505            vec![
1506                Value::String("Padding".into()),
1507                Value::String("inclusive".into()),
1508            ],
1509        )
1510        .unwrap_err();
1511        assert_eq!(err.to_string(), EXTRACT_BETWEEN_ERROR_OPTION_NAME.message);
1512        assert_eq!(
1513            err.identifier(),
1514            EXTRACT_BETWEEN_ERROR_OPTION_NAME.identifier
1515        );
1516    }
1517
1518    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1519    #[test]
1520    fn extractBetween_option_pair_error() {
1521        let err = extract_between_builtin(
1522            Value::String("abc".into()),
1523            Value::String("a".into()),
1524            Value::String("b".into()),
1525            vec![Value::String("Boundaries".into())],
1526        )
1527        .unwrap_err();
1528        assert_eq!(
1529            err.to_string(),
1530            EXTRACT_BETWEEN_ERROR_NAME_VALUE_PAIR.message
1531        );
1532        assert_eq!(
1533            err.identifier(),
1534            EXTRACT_BETWEEN_ERROR_NAME_VALUE_PAIR.identifier
1535        );
1536    }
1537
1538    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1539    #[test]
1540    fn extractBetween_missing_boundary_propagates() {
1541        let result = extract_between_builtin(
1542            Value::String("Planner<GPU>".into()),
1543            Value::String("<missing>".into()),
1544            Value::String(">".into()),
1545            Vec::new(),
1546        )
1547        .expect("extractBetween");
1548        assert_eq!(result, Value::String("<missing>".into()));
1549    }
1550
1551    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1552    #[test]
1553    fn extractBetween_cell_boundary_arguments() {
1554        let text =
1555            CellArray::new(vec![Value::CharArray(CharArray::new_row("A<GPU>"))], 1, 1).unwrap();
1556        let start = CellArray::new(vec![Value::CharArray(CharArray::new_row("<"))], 1, 1).unwrap();
1557        let stop = CellArray::new(vec![Value::CharArray(CharArray::new_row(">"))], 1, 1).unwrap();
1558        let result = extract_between_builtin(
1559            Value::Cell(text),
1560            Value::Cell(start),
1561            Value::Cell(stop),
1562            Vec::new(),
1563        )
1564        .expect("extractBetween");
1565        match result {
1566            Value::Cell(out) => {
1567                let value = out.get(0, 0).unwrap();
1568                assert_eq!(value, Value::CharArray(CharArray::new_row("GPU")));
1569            }
1570            other => panic!("expected cell array, got {other:?}"),
1571        }
1572    }
1573
1574    #[test]
1575    fn extract_between_type_preserves_text() {
1576        assert_eq!(
1577            text_preserve_type(&[Type::String], &ResolveContext::new(Vec::new())),
1578            Type::String
1579        );
1580    }
1581}