Skip to main content

runmat_runtime/builtins/io/
textscan.rs

1//! MATLAB-compatible `textscan` builtin for formatted text imports.
2
3use std::collections::HashSet;
4use std::io::{Read, Seek, SeekFrom};
5
6use encoding_rs::{Encoding, SHIFT_JIS};
7use runmat_builtins::{
8    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10    CellArray, Tensor, Value,
11};
12use runmat_macros::runtime_builtin;
13
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::io::filetext::{helpers::decode_bytes, registry};
19use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
20
21const BUILTIN_NAME: &str = "textscan";
22
23const TEXTSCAN_OUTPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
24    name: "C",
25    ty: BuiltinParamType::Any,
26    arity: BuiltinParamArity::Required,
27    default: None,
28    description: "Cell array containing one output per conversion, or collected groups.",
29}];
30const TEXTSCAN_INPUTS_TEXT_FORMAT: [BuiltinParamDescriptor; 2] = [
31    BuiltinParamDescriptor {
32        name: "textOrFileID",
33        ty: BuiltinParamType::Any,
34        arity: BuiltinParamArity::Required,
35        default: None,
36        description: "Input text or file identifier opened by fopen.",
37    },
38    BuiltinParamDescriptor {
39        name: "formatSpec",
40        ty: BuiltinParamType::StringScalar,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Format specification such as '%f %s'.",
44    },
45];
46const TEXTSCAN_INPUTS_TEXT_FORMAT_OPTIONS: [BuiltinParamDescriptor; 3] = [
47    BuiltinParamDescriptor {
48        name: "textOrFileID",
49        ty: BuiltinParamType::Any,
50        arity: BuiltinParamArity::Required,
51        default: None,
52        description: "Input text or file identifier opened by fopen.",
53    },
54    BuiltinParamDescriptor {
55        name: "formatSpec",
56        ty: BuiltinParamType::StringScalar,
57        arity: BuiltinParamArity::Required,
58        default: None,
59        description: "Format specification such as '%f %s'.",
60    },
61    BuiltinParamDescriptor {
62        name: "args...",
63        ty: BuiltinParamType::Any,
64        arity: BuiltinParamArity::Variadic,
65        default: None,
66        description: "Optional repeat count followed by name-value pairs.",
67    },
68];
69const TEXTSCAN_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
70    BuiltinSignatureDescriptor {
71        label: "C = textscan(textOrFileID, formatSpec)",
72        inputs: &TEXTSCAN_INPUTS_TEXT_FORMAT,
73        outputs: &TEXTSCAN_OUTPUTS,
74    },
75    BuiltinSignatureDescriptor {
76        label: "C = textscan(textOrFileID, formatSpec, args...)",
77        inputs: &TEXTSCAN_INPUTS_TEXT_FORMAT_OPTIONS,
78        outputs: &TEXTSCAN_OUTPUTS,
79    },
80];
81
82const TEXTSCAN_ERROR_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
83    code: "RM.TEXTSCAN.ARGUMENT",
84    identifier: Some("RunMat:textscan:InvalidArgument"),
85    when: "Input, format specification, repeat count, or name-value options are malformed.",
86    message: "textscan: invalid argument",
87};
88const TEXTSCAN_ERROR_FORMAT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
89    code: "RM.TEXTSCAN.FORMAT",
90    identifier: Some("RunMat:textscan:InvalidFormat"),
91    when: "Format specification cannot be parsed or contains unsupported conversions.",
92    message: "textscan: invalid format specification",
93};
94const TEXTSCAN_ERROR_FILE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
95    code: "RM.TEXTSCAN.FILE",
96    identifier: Some("RunMat:textscan:File"),
97    when: "A file identifier is invalid or cannot be read.",
98    message: "textscan: file read failed",
99};
100const TEXTSCAN_ERROR_PARSE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
101    code: "RM.TEXTSCAN.PARSE",
102    identifier: Some("RunMat:textscan:Parse"),
103    when: "Input text cannot be parsed according to the format specification.",
104    message: "textscan: parse failed",
105};
106const TEXTSCAN_ERRORS: [BuiltinErrorDescriptor; 4] = [
107    TEXTSCAN_ERROR_ARGUMENT,
108    TEXTSCAN_ERROR_FORMAT,
109    TEXTSCAN_ERROR_FILE,
110    TEXTSCAN_ERROR_PARSE,
111];
112
113pub const TEXTSCAN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
114    signatures: &TEXTSCAN_SIGNATURES,
115    output_mode: BuiltinOutputMode::Fixed,
116    completion_policy: BuiltinCompletionPolicy::Public,
117    errors: &TEXTSCAN_ERRORS,
118};
119
120#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::textscan")]
121pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
122    name: "textscan",
123    op_kind: GpuOpKind::Custom("io-textscan"),
124    supported_precisions: &[],
125    broadcast: BroadcastSemantics::None,
126    provider_hooks: &[],
127    constant_strategy: ConstantStrategy::InlineLiteral,
128    residency: ResidencyPolicy::GatherImmediately,
129    nan_mode: ReductionNaN::Include,
130    two_pass_threshold: None,
131    workgroup_size: None,
132    accepts_nan_mode: false,
133    notes: "Runs on the host; formatted text import is not an acceleration operation.",
134};
135
136#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::textscan")]
137pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
138    name: "textscan",
139    shape: ShapeRequirements::Any,
140    constant_strategy: ConstantStrategy::InlineLiteral,
141    elementwise: None,
142    reduction: None,
143    emits_nan: false,
144    notes: "Not eligible for fusion; performs host-side formatted text parsing.",
145};
146
147fn textscan_error_with(
148    error: &'static BuiltinErrorDescriptor,
149    message: impl Into<String>,
150) -> RuntimeError {
151    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
152    if let Some(identifier) = error.identifier {
153        builder = builder.with_identifier(identifier);
154    }
155    builder.build()
156}
157
158fn textscan_error_with_source<E>(
159    error: &'static BuiltinErrorDescriptor,
160    message: impl Into<String>,
161    source: E,
162) -> RuntimeError
163where
164    E: std::error::Error + Send + Sync + 'static,
165{
166    let mut builder = build_runtime_error(message)
167        .with_builtin(BUILTIN_NAME)
168        .with_source(source);
169    if let Some(identifier) = error.identifier {
170        builder = builder.with_identifier(identifier);
171    }
172    builder.build()
173}
174
175fn map_control_flow(err: RuntimeError) -> RuntimeError {
176    let identifier = err.identifier().map(|value| value.to_string());
177    let message = err.message().to_string();
178    let mut builder = build_runtime_error(message)
179        .with_builtin(BUILTIN_NAME)
180        .with_source(err);
181    if let Some(identifier) = identifier {
182        builder = builder.with_identifier(identifier);
183    }
184    builder.build()
185}
186
187#[runtime_builtin(
188    name = "textscan",
189    category = "io/import",
190    summary = "Parse formatted text from a string or file identifier.",
191    keywords = "textscan,formatted text,delimiter,header,format specifier,csv,log import",
192    accel = "cpu",
193    type_resolver(crate::builtins::io::type_resolvers::textscan_type),
194    descriptor(crate::builtins::io::textscan::TEXTSCAN_DESCRIPTOR),
195    builtin_path = "crate::builtins::io::textscan"
196)]
197async fn textscan_builtin(
198    input: Value,
199    format_spec: Value,
200    rest: Vec<Value>,
201) -> BuiltinResult<Value> {
202    let input = gather_if_needed_async(&input)
203        .await
204        .map_err(map_control_flow)?;
205    let format_spec = gather_if_needed_async(&format_spec)
206        .await
207        .map_err(map_control_flow)?;
208    let format_spec = string_scalar(&format_spec, "formatSpec")?;
209    let gathered_rest = gather_rest(rest).await?;
210    let (repeat, options) = parse_args(&gathered_rest)?;
211    let parsed = parse_input(&input, &format_spec, repeat, &options)?;
212    build_output(parsed, &options)
213}
214
215async fn gather_rest(rest: Vec<Value>) -> BuiltinResult<Vec<Value>> {
216    let mut out = Vec::with_capacity(rest.len());
217    for value in rest {
218        out.push(
219            gather_if_needed_async(&value)
220                .await
221                .map_err(map_control_flow)?,
222        );
223    }
224    Ok(out)
225}
226
227fn parse_input(
228    value: &Value,
229    format_spec: &str,
230    repeat: Option<usize>,
231    options: &TextscanOptions,
232) -> BuiltinResult<Vec<ColumnData>> {
233    if let Some(fid) = numeric_fid(value) {
234        return parse_registered_file(fid, format_spec, repeat, options)
235            .map(|parsed| parsed.columns);
236    }
237    let text = string_scalar(value, "textOrFileID")?;
238    parse_textscan(&text, format_spec, repeat, options).map(|parsed| parsed.columns)
239}
240
241fn parse_registered_file(
242    fid: i32,
243    format_spec: &str,
244    repeat: Option<usize>,
245    options: &TextscanOptions,
246) -> BuiltinResult<ParsedTextscan> {
247    validate_fid(fid)?;
248    let info = registry::info_for(fid).ok_or_else(|| {
249        textscan_error_with(
250            &TEXTSCAN_ERROR_FILE,
251            format!("textscan: invalid file identifier {fid}"),
252        )
253    })?;
254    if !permission_allows_read(&info.permission) {
255        return Err(textscan_error_with(
256            &TEXTSCAN_ERROR_FILE,
257            format!("textscan: file identifier {fid} is not open for reading"),
258        ));
259    }
260    let handle = registry::shared_handle(fid).ok_or_else(|| {
261        textscan_error_with(
262            &TEXTSCAN_ERROR_FILE,
263            format!("textscan: invalid file identifier {fid}"),
264        )
265    })?;
266    let mut guard = handle
267        .lock()
268        .map_err(|_| textscan_error_with(&TEXTSCAN_ERROR_FILE, "textscan: file handle poisoned"))?;
269    let file = guard.as_mut().ok_or_else(|| {
270        textscan_error_with(
271            &TEXTSCAN_ERROR_FILE,
272            format!("textscan: file identifier {fid} is closed"),
273        )
274    })?;
275    let start = file.stream_position().map_err(|err| {
276        textscan_error_with_source(
277            &TEXTSCAN_ERROR_FILE,
278            format!("textscan: unable to seek file identifier {fid} ({err})"),
279            err,
280        )
281    })?;
282    let mut bytes = Vec::new();
283    file.read_to_end(&mut bytes).map_err(|err| {
284        textscan_error_with_source(
285            &TEXTSCAN_ERROR_FILE,
286            format!("textscan: unable to read from file identifier {fid} ({err})"),
287            err,
288        )
289    })?;
290    let encoding = if info.encoding.trim().is_empty() {
291        "UTF-8"
292    } else {
293        info.encoding.as_str()
294    };
295    let decoded = DecodedFileText::decode(&bytes, encoding)?;
296    let parsed = parse_textscan(&decoded.text, format_spec, repeat, options)?;
297    let consumed_bytes = decoded.byte_offset_for_text_pos(parsed.consumed_text_pos)?;
298    let target = start.saturating_add(consumed_bytes as u64);
299    file.seek(SeekFrom::Start(target)).map_err(|err| {
300        textscan_error_with_source(
301            &TEXTSCAN_ERROR_FILE,
302            format!("textscan: unable to restore file position for identifier {fid} ({err})"),
303            err,
304        )
305    })?;
306    Ok(parsed)
307}
308
309fn validate_fid(fid: i32) -> BuiltinResult<()> {
310    if fid < 0 {
311        return Err(textscan_error_with(
312            &TEXTSCAN_ERROR_FILE,
313            "textscan: file identifier must be non-negative",
314        ));
315    }
316    if fid < 3 {
317        return Err(textscan_error_with(
318            &TEXTSCAN_ERROR_FILE,
319            "textscan: standard input/output identifiers are not supported yet",
320        ));
321    }
322    Ok(())
323}
324
325fn permission_allows_read(permission: &str) -> bool {
326    let lower = permission.to_ascii_lowercase();
327    lower.starts_with('r') || lower.contains('+')
328}
329
330struct DecodedFileText {
331    text: String,
332    text_offsets: Vec<usize>,
333    byte_offsets: Vec<usize>,
334    byte_len: usize,
335}
336
337impl DecodedFileText {
338    fn decode(bytes: &[u8], encoding: &str) -> BuiltinResult<Self> {
339        if is_shift_jis_encoding(encoding) {
340            return decode_shift_jis_with_offsets(bytes, encoding);
341        }
342        let chars = decode_bytes(bytes, encoding, BUILTIN_NAME)
343            .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_FILE, err.message()))?;
344        let byte_width = byte_preserving_encoding_width(encoding)?;
345        let mut text = String::new();
346        let mut text_offsets = Vec::with_capacity(chars.len());
347        let mut byte_offsets = Vec::with_capacity(chars.len());
348        let mut byte_offset = 0usize;
349        for ch in chars {
350            text_offsets.push(text.len());
351            byte_offsets.push(byte_offset);
352            text.push(ch);
353            byte_offset += byte_width.unwrap_or_else(|| ch.len_utf8());
354        }
355        Ok(Self {
356            text,
357            text_offsets,
358            byte_offsets,
359            byte_len: bytes.len(),
360        })
361    }
362
363    fn byte_offset_for_text_pos(&self, text_pos: usize) -> BuiltinResult<usize> {
364        if text_pos == self.text.len() {
365            return Ok(self.byte_len);
366        }
367        match self.text_offsets.binary_search(&text_pos) {
368            Ok(idx) => Ok(self.byte_offsets[idx]),
369            Err(idx) if idx == self.text_offsets.len() => Ok(self.byte_len),
370            Err(_) => Err(textscan_error_with(
371                &TEXTSCAN_ERROR_FILE,
372                "textscan: parsed position did not fall on a decoded character boundary",
373            )),
374        }
375    }
376}
377
378fn decode_shift_jis_with_offsets(bytes: &[u8], encoding: &str) -> BuiltinResult<DecodedFileText> {
379    let mut text = String::new();
380    let mut text_offsets = Vec::new();
381    let mut byte_offsets = Vec::new();
382    let mut byte_offset = 0usize;
383    while byte_offset < bytes.len() {
384        let width = shift_jis_unit_width(bytes, byte_offset)?;
385        let decoded = decode_bytes(
386            &bytes[byte_offset..byte_offset + width],
387            encoding,
388            BUILTIN_NAME,
389        )
390        .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_FILE, err.message()))?;
391        for ch in decoded {
392            text_offsets.push(text.len());
393            byte_offsets.push(byte_offset);
394            text.push(ch);
395        }
396        byte_offset += width;
397    }
398    Ok(DecodedFileText {
399        text,
400        text_offsets,
401        byte_offsets,
402        byte_len: bytes.len(),
403    })
404}
405
406fn shift_jis_unit_width(bytes: &[u8], offset: usize) -> BuiltinResult<usize> {
407    let first = bytes[offset];
408    if first <= 0x80 || (0xA1..=0xDF).contains(&first) {
409        return Ok(1);
410    }
411    if (0x81..=0x9F).contains(&first) || (0xE0..=0xFC).contains(&first) {
412        let Some(&second) = bytes.get(offset + 1) else {
413            return Err(textscan_error_with(
414                &TEXTSCAN_ERROR_FILE,
415                "textscan: incomplete Shift_JIS character at end of file",
416            ));
417        };
418        if (0x40..=0x7E).contains(&second) || (0x80..=0xFC).contains(&second) {
419            return Ok(2);
420        }
421    }
422    Err(textscan_error_with(
423        &TEXTSCAN_ERROR_FILE,
424        "textscan: invalid Shift_JIS byte sequence",
425    ))
426}
427
428fn is_shift_jis_encoding(encoding: &str) -> bool {
429    Encoding::for_label(encoding.trim().as_bytes()) == Some(SHIFT_JIS)
430}
431
432fn byte_preserving_encoding_width(encoding: &str) -> BuiltinResult<Option<usize>> {
433    let label = encoding.trim();
434    if label.is_empty() || label.eq_ignore_ascii_case("utf-8") || label.eq_ignore_ascii_case("utf8")
435    {
436        return Ok(None);
437    }
438    if is_shift_jis_encoding(label) {
439        return Ok(None);
440    }
441    if label.eq_ignore_ascii_case("binary")
442        || label.eq_ignore_ascii_case("latin1")
443        || label.eq_ignore_ascii_case("latin-1")
444        || label.eq_ignore_ascii_case("iso-8859-1")
445        || label.eq_ignore_ascii_case("windows-1252")
446        || label.eq_ignore_ascii_case("cp1252")
447        || label.eq_ignore_ascii_case("us-ascii")
448        || label.eq_ignore_ascii_case("ascii")
449        || label.eq_ignore_ascii_case("us_ascii")
450        || label.eq_ignore_ascii_case("usascii")
451    {
452        return Ok(Some(1));
453    }
454    Err(textscan_error_with(
455        &TEXTSCAN_ERROR_FILE,
456        format!(
457            "textscan: file-position preserving reads do not yet support encoding '{encoding}'"
458        ),
459    ))
460}
461
462#[derive(Debug, Clone)]
463struct TextscanOptions {
464    delimiters: Vec<String>,
465    whitespace: String,
466    multiple_delims_as_one: bool,
467    header_lines: usize,
468    treat_as_empty: Vec<String>,
469    comment_style: CommentStyle,
470    collect_output: bool,
471    return_on_error: bool,
472}
473
474impl Default for TextscanOptions {
475    fn default() -> Self {
476        Self {
477            delimiters: Vec::new(),
478            whitespace: " \u{0008}\t".to_string(),
479            multiple_delims_as_one: false,
480            header_lines: 0,
481            treat_as_empty: Vec::new(),
482            comment_style: CommentStyle::None,
483            collect_output: false,
484            return_on_error: true,
485        }
486    }
487}
488
489#[derive(Debug, Clone, PartialEq, Eq)]
490enum CommentStyle {
491    None,
492    Line(Vec<String>),
493    Block { start: String, end: String },
494}
495
496fn parse_args(args: &[Value]) -> BuiltinResult<(Option<usize>, TextscanOptions)> {
497    let mut idx = 0usize;
498    let repeat = if let Some(value) = args.first() {
499        if is_numeric_scalar(value) {
500            idx = 1;
501            Some(nonnegative_usize(value, "repeat count")?)
502        } else {
503            None
504        }
505    } else {
506        None
507    };
508    if !(args.len() - idx).is_multiple_of(2) {
509        return Err(textscan_error_with(
510            &TEXTSCAN_ERROR_ARGUMENT,
511            "textscan: options must be provided as name-value pairs",
512        ));
513    }
514    let mut options = TextscanOptions::default();
515    while idx < args.len() {
516        let name = string_scalar(&args[idx], "option name")?;
517        let value = &args[idx + 1];
518        apply_option(&mut options, &name, value)?;
519        idx += 2;
520    }
521    Ok((repeat, options))
522}
523
524fn apply_option(options: &mut TextscanOptions, name: &str, value: &Value) -> BuiltinResult<()> {
525    match normalize_name(name).as_str() {
526        "delimiter" => options.delimiters = delimiter_list(value)?,
527        "multipledelimsasone" => options.multiple_delims_as_one = bool_like(value, name)?,
528        "headerlines" => options.header_lines = nonnegative_usize(value, name)?,
529        "treatasempty" => options.treat_as_empty = string_list(value, name)?,
530        "commentstyle" => options.comment_style = parse_comment_style(value)?,
531        "collectoutput" => options.collect_output = bool_like(value, name)?,
532        "returnonerror" => options.return_on_error = bool_like(value, name)?,
533        "whitespace" => options.whitespace = string_scalar(value, name)?,
534        "emptyvalue" | "endofline" | "bufsize" | "expchars" | "texttype" | "datelocale" => {
535            return Err(textscan_error_with(
536                &TEXTSCAN_ERROR_ARGUMENT,
537                format!("textscan: option '{name}' is not implemented yet"),
538            ));
539        }
540        other => {
541            return Err(textscan_error_with(
542                &TEXTSCAN_ERROR_ARGUMENT,
543                format!("textscan: unsupported option '{other}'"),
544            ));
545        }
546    }
547    Ok(())
548}
549
550fn normalize_name(name: &str) -> String {
551    name.chars()
552        .filter(|ch| *ch != '_' && *ch != ' ')
553        .flat_map(char::to_lowercase)
554        .collect()
555}
556
557#[derive(Debug, Clone, PartialEq, Eq)]
558struct FormatItem {
559    kind: FormatKind,
560    skip: bool,
561    width: Option<usize>,
562}
563
564#[derive(Debug, Clone, PartialEq, Eq)]
565enum FormatKind {
566    Float,
567    SignedInt,
568    UnsignedInt,
569    String,
570    QuotedString,
571    Char,
572    CharSet { chars: HashSet<char>, negated: bool },
573}
574
575impl FormatKind {
576    fn output_kind(&self) -> OutputKind {
577        match self {
578            FormatKind::Float | FormatKind::SignedInt | FormatKind::UnsignedInt => {
579                OutputKind::Numeric
580            }
581            FormatKind::String
582            | FormatKind::QuotedString
583            | FormatKind::Char
584            | FormatKind::CharSet { .. } => OutputKind::Text,
585        }
586    }
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum OutputKind {
591    Numeric,
592    Text,
593}
594
595#[derive(Debug, Clone, PartialEq, Eq)]
596enum FormatElement {
597    Conversion(FormatItem),
598    Literal(String),
599    Whitespace,
600}
601
602fn parse_format_spec(format: &str) -> BuiltinResult<Vec<FormatElement>> {
603    let mut elements = Vec::new();
604    let mut conversion_count = 0usize;
605    let chars: Vec<char> = format.chars().collect();
606    let mut idx = 0usize;
607    while idx < chars.len() {
608        if chars[idx] != '%' {
609            if chars[idx].is_whitespace() {
610                while idx < chars.len() && chars[idx].is_whitespace() {
611                    idx += 1;
612                }
613                if !matches!(elements.last(), Some(FormatElement::Whitespace)) {
614                    elements.push(FormatElement::Whitespace);
615                }
616                continue;
617            }
618            let start = idx;
619            while idx < chars.len() && chars[idx] != '%' && !chars[idx].is_whitespace() {
620                idx += 1;
621            }
622            elements.push(FormatElement::Literal(chars[start..idx].iter().collect()));
623            continue;
624        }
625        idx += 1;
626        if idx < chars.len() && chars[idx] == '%' {
627            elements.push(FormatElement::Literal("%".to_string()));
628            idx += 1;
629            continue;
630        }
631        let mut skip = false;
632        if idx < chars.len() && chars[idx] == '*' {
633            skip = true;
634            idx += 1;
635        }
636        let width_start = idx;
637        while idx < chars.len() && chars[idx].is_ascii_digit() {
638            idx += 1;
639        }
640        let width = if idx > width_start {
641            Some(
642                chars[width_start..idx]
643                    .iter()
644                    .collect::<String>()
645                    .parse::<usize>()
646                    .map_err(|_| {
647                        textscan_error_with(&TEXTSCAN_ERROR_FORMAT, "textscan: invalid field width")
648                    })?,
649            )
650        } else {
651            None
652        };
653        if idx >= chars.len() {
654            return Err(textscan_error_with(
655                &TEXTSCAN_ERROR_FORMAT,
656                "textscan: incomplete conversion specifier",
657            ));
658        }
659        let kind = match chars[idx] {
660            'f' | 'e' | 'E' | 'g' | 'G' | 'n' => {
661                idx += 1;
662                FormatKind::Float
663            }
664            'd' | 'i' => {
665                idx += 1;
666                FormatKind::SignedInt
667            }
668            'u' => {
669                idx += 1;
670                FormatKind::UnsignedInt
671            }
672            's' => {
673                idx += 1;
674                FormatKind::String
675            }
676            'q' => {
677                idx += 1;
678                FormatKind::QuotedString
679            }
680            'c' => {
681                idx += 1;
682                FormatKind::Char
683            }
684            '[' => {
685                idx += 1;
686                let mut negated = false;
687                if idx < chars.len() && chars[idx] == '^' {
688                    negated = true;
689                    idx += 1;
690                }
691                let mut set = HashSet::new();
692                while idx < chars.len() && chars[idx] != ']' {
693                    set.insert(chars[idx]);
694                    idx += 1;
695                }
696                if idx >= chars.len() {
697                    return Err(textscan_error_with(
698                        &TEXTSCAN_ERROR_FORMAT,
699                        "textscan: unterminated character set conversion",
700                    ));
701                }
702                idx += 1;
703                FormatKind::CharSet {
704                    chars: set,
705                    negated,
706                }
707            }
708            other => {
709                return Err(textscan_error_with(
710                    &TEXTSCAN_ERROR_FORMAT,
711                    format!("textscan: unsupported conversion '%{other}'"),
712                ));
713            }
714        };
715        elements.push(FormatElement::Conversion(FormatItem { kind, skip, width }));
716        conversion_count += 1;
717    }
718    if conversion_count == 0 {
719        return Err(textscan_error_with(
720            &TEXTSCAN_ERROR_FORMAT,
721            "textscan: formatSpec must contain at least one conversion",
722        ));
723    }
724    Ok(elements)
725}
726
727#[derive(Debug, Clone)]
728enum ColumnData {
729    Numeric(Vec<f64>),
730    Text(Vec<String>),
731}
732
733impl ColumnData {
734    fn new(kind: OutputKind) -> Self {
735        match kind {
736            OutputKind::Numeric => ColumnData::Numeric(Vec::new()),
737            OutputKind::Text => ColumnData::Text(Vec::new()),
738        }
739    }
740
741    fn kind(&self) -> OutputKind {
742        match self {
743            ColumnData::Numeric(_) => OutputKind::Numeric,
744            ColumnData::Text(_) => OutputKind::Text,
745        }
746    }
747
748    fn len(&self) -> usize {
749        match self {
750            ColumnData::Numeric(values) => values.len(),
751            ColumnData::Text(values) => values.len(),
752        }
753    }
754
755    fn truncate(&mut self, len: usize) {
756        match self {
757            ColumnData::Numeric(values) => values.truncate(len),
758            ColumnData::Text(values) => values.truncate(len),
759        }
760    }
761
762    fn push_numeric(&mut self, value: f64) {
763        let ColumnData::Numeric(values) = self else {
764            unreachable!("numeric pushed into text column");
765        };
766        values.push(value);
767    }
768
769    fn push_text(&mut self, value: String) {
770        let ColumnData::Text(values) = self else {
771            unreachable!("text pushed into numeric column");
772        };
773        values.push(value);
774    }
775}
776
777fn parse_textscan(
778    text: &str,
779    format: &str,
780    repeat: Option<usize>,
781    options: &TextscanOptions,
782) -> BuiltinResult<ParsedTextscan> {
783    let elements = parse_format_spec(format)?;
784    let output_kinds: Vec<OutputKind> = elements
785        .iter()
786        .filter_map(|element| match element {
787            FormatElement::Conversion(item) if !item.skip => Some(item.kind.output_kind()),
788            _ => None,
789        })
790        .collect();
791    let mut columns: Vec<ColumnData> = output_kinds.into_iter().map(ColumnData::new).collect();
792    let mut scanner = TextScanner::new(text, options);
793    scanner.skip_header_lines();
794    let mut records = 0usize;
795    while !scanner.is_eof() && repeat.map(|limit| records < limit).unwrap_or(true) {
796        scanner.skip_separators();
797        if scanner.is_eof() {
798            break;
799        }
800        let row_len = columns.first().map(ColumnData::len).unwrap_or(0);
801        let mut output_idx = 0usize;
802        let row_start = scanner.pos;
803        let mut failed = false;
804        for idx in 0..elements.len() {
805            match &elements[idx] {
806                FormatElement::Whitespace => scanner.skip_format_whitespace(),
807                FormatElement::Literal(literal) => {
808                    if let Err(err) = scanner.consume_literal(literal) {
809                        if options.return_on_error {
810                            failed = true;
811                            break;
812                        }
813                        return Err(err);
814                    }
815                }
816                FormatElement::Conversion(item) => {
817                    let next_literal = next_literal(&elements[idx + 1..]);
818                    let parsed = match scanner.parse_conversion(item, next_literal) {
819                        Ok(parsed) => parsed,
820                        Err(_) if options.return_on_error => {
821                            failed = true;
822                            break;
823                        }
824                        Err(err) => return Err(err),
825                    };
826                    let Some(parsed) = parsed else {
827                        continue;
828                    };
829                    match parsed {
830                        ParsedValue::Number(value) => {
831                            if !item.skip {
832                                columns[output_idx].push_numeric(value);
833                                output_idx += 1;
834                            }
835                        }
836                        ParsedValue::Text(value) => {
837                            if !item.skip {
838                                columns[output_idx].push_text(value);
839                                output_idx += 1;
840                            }
841                        }
842                    }
843                }
844            }
845        }
846        if failed || output_idx < columns.len() {
847            for column in &mut columns {
848                column.truncate(row_len);
849            }
850            break;
851        }
852        if scanner.pos == row_start {
853            break;
854        }
855        records += 1;
856        scanner.skip_separators();
857    }
858    Ok(ParsedTextscan {
859        columns,
860        consumed_text_pos: scanner.pos,
861    })
862}
863
864#[derive(Debug, Clone)]
865struct ParsedTextscan {
866    columns: Vec<ColumnData>,
867    consumed_text_pos: usize,
868}
869
870fn next_literal(elements: &[FormatElement]) -> Option<&str> {
871    if let Some(element) = elements.first() {
872        return match element {
873            FormatElement::Literal(literal) => Some(literal),
874            FormatElement::Whitespace | FormatElement::Conversion(_) => None,
875        };
876    }
877    None
878}
879
880struct TextScanner<'a> {
881    text: &'a str,
882    pos: usize,
883    options: &'a TextscanOptions,
884    whitespace: HashSet<char>,
885    delimiters: Vec<String>,
886}
887
888impl<'a> TextScanner<'a> {
889    fn new(text: &'a str, options: &'a TextscanOptions) -> Self {
890        let mut delimiters = options.delimiters.clone();
891        delimiters.push("\r\n".to_string());
892        delimiters.push("\n".to_string());
893        delimiters.push("\r".to_string());
894        delimiters.sort_by_key(|delimiter| std::cmp::Reverse(delimiter.len()));
895        Self {
896            text,
897            pos: 0,
898            options,
899            whitespace: options.whitespace.chars().collect(),
900            delimiters,
901        }
902    }
903
904    fn is_eof(&self) -> bool {
905        self.pos >= self.text.len()
906    }
907
908    fn current_char(&self) -> Option<char> {
909        self.text[self.pos..].chars().next()
910    }
911
912    fn skip_header_lines(&mut self) {
913        for _ in 0..self.options.header_lines {
914            while let Some(ch) = self.current_char() {
915                self.pos += ch.len_utf8();
916                if ch == '\n' {
917                    break;
918                }
919            }
920        }
921    }
922
923    fn skip_format_whitespace(&mut self) {
924        while let Some(ch) = self.current_char() {
925            if !ch.is_whitespace() {
926                break;
927            }
928            self.pos += ch.len_utf8();
929        }
930    }
931
932    fn skip_separators(&mut self) {
933        loop {
934            if self.skip_comment() {
935                continue;
936            }
937            if let Some(delimiter) = self.match_delimiter() {
938                self.pos += delimiter.len();
939                continue;
940            }
941            let Some(ch) = self.current_char() else {
942                break;
943            };
944            if ch.is_whitespace() || self.whitespace.contains(&ch) {
945                self.pos += ch.len_utf8();
946                continue;
947            }
948            break;
949        }
950    }
951
952    fn consume_literal(&mut self, literal: &str) -> BuiltinResult<()> {
953        if self.text[self.pos..].starts_with(literal) {
954            self.pos += literal.len();
955            return Ok(());
956        }
957        Err(textscan_error_with(
958            &TEXTSCAN_ERROR_PARSE,
959            format!("textscan: expected literal '{literal}'"),
960        ))
961    }
962
963    fn parse_conversion(
964        &mut self,
965        item: &FormatItem,
966        next_literal: Option<&str>,
967    ) -> BuiltinResult<Option<ParsedValue>> {
968        if !matches!(item.kind, FormatKind::Char | FormatKind::CharSet { .. }) {
969            self.skip_separators();
970        }
971        let parsed = match &item.kind {
972            FormatKind::Float => ParsedValue::Number(self.parse_numeric_field(
973                item.width,
974                next_literal,
975                parse_float,
976            )?),
977            FormatKind::SignedInt => ParsedValue::Number(self.parse_numeric_field(
978                item.width,
979                next_literal,
980                |field| parse_signed_int(field).map(|value| value as f64),
981            )?),
982            FormatKind::UnsignedInt => ParsedValue::Number(self.parse_numeric_field(
983                item.width,
984                next_literal,
985                |field| parse_unsigned_int(field).map(|value| value as f64),
986            )?),
987            FormatKind::String => {
988                ParsedValue::Text(self.read_field(item.width, next_literal, true)?)
989            }
990            FormatKind::QuotedString => {
991                ParsedValue::Text(self.read_quoted_or_field(item.width, next_literal)?)
992            }
993            FormatKind::Char => ParsedValue::Text(self.read_chars(item.width.unwrap_or(1))?),
994            FormatKind::CharSet { chars, negated } => {
995                ParsedValue::Text(self.read_charset(chars, *negated, item.width)?)
996            }
997        };
998        if item.skip {
999            Ok(None)
1000        } else {
1001            Ok(Some(parsed))
1002        }
1003    }
1004
1005    fn parse_numeric_field(
1006        &mut self,
1007        width: Option<usize>,
1008        next_literal: Option<&str>,
1009        parse: impl FnOnce(&str) -> BuiltinResult<f64>,
1010    ) -> BuiltinResult<f64> {
1011        let field = self.read_field(width, next_literal, false)?;
1012        if self
1013            .options
1014            .treat_as_empty
1015            .iter()
1016            .any(|empty| empty == &field)
1017        {
1018            return Ok(f64::NAN);
1019        }
1020        parse(&field)
1021    }
1022
1023    fn read_field(
1024        &mut self,
1025        width: Option<usize>,
1026        next_literal: Option<&str>,
1027        allow_treat_empty: bool,
1028    ) -> BuiltinResult<String> {
1029        let start = self.pos;
1030        let mut chars = 0usize;
1031        while !self.is_eof() {
1032            if width.map(|limit| chars >= limit).unwrap_or(false) {
1033                break;
1034            }
1035            if next_literal
1036                .filter(|literal| self.text[self.pos..].starts_with(*literal))
1037                .is_some()
1038            {
1039                break;
1040            }
1041            if self.is_at_separator() || self.is_at_comment() {
1042                break;
1043            }
1044            let Some(ch) = self.current_char() else {
1045                break;
1046            };
1047            self.pos += ch.len_utf8();
1048            chars += 1;
1049        }
1050        let field = self.text[start..self.pos].trim().to_string();
1051        if allow_treat_empty
1052            && self
1053                .options
1054                .treat_as_empty
1055                .iter()
1056                .any(|empty| empty == &field)
1057        {
1058            return Ok(String::new());
1059        }
1060        if field.is_empty() {
1061            return Err(textscan_error_with(
1062                &TEXTSCAN_ERROR_PARSE,
1063                "textscan: empty field",
1064            ));
1065        }
1066        Ok(field)
1067    }
1068
1069    fn read_quoted_or_field(
1070        &mut self,
1071        width: Option<usize>,
1072        next_literal: Option<&str>,
1073    ) -> BuiltinResult<String> {
1074        if self.current_char() != Some('"') {
1075            return self.read_field(width, next_literal, true);
1076        }
1077        self.pos += 1;
1078        let mut out = String::new();
1079        while let Some(ch) = self.current_char() {
1080            self.pos += ch.len_utf8();
1081            if ch == '"' {
1082                if self.current_char() == Some('"') {
1083                    self.pos += 1;
1084                    out.push('"');
1085                    continue;
1086                }
1087                return Ok(out);
1088            }
1089            if width
1090                .map(|limit| out.chars().count() >= limit)
1091                .unwrap_or(false)
1092            {
1093                return Ok(out);
1094            }
1095            out.push(ch);
1096        }
1097        Err(textscan_error_with(
1098            &TEXTSCAN_ERROR_PARSE,
1099            "textscan: unterminated quoted field",
1100        ))
1101    }
1102
1103    fn read_chars(&mut self, count: usize) -> BuiltinResult<String> {
1104        let mut out = String::new();
1105        for _ in 0..count {
1106            let Some(ch) = self.current_char() else {
1107                return Err(textscan_error_with(
1108                    &TEXTSCAN_ERROR_PARSE,
1109                    "textscan: not enough characters for %c conversion",
1110                ));
1111            };
1112            self.pos += ch.len_utf8();
1113            out.push(ch);
1114        }
1115        Ok(out)
1116    }
1117
1118    fn read_charset(
1119        &mut self,
1120        chars: &HashSet<char>,
1121        negated: bool,
1122        width: Option<usize>,
1123    ) -> BuiltinResult<String> {
1124        let mut out = String::new();
1125        while let Some(ch) = self.current_char() {
1126            if width
1127                .map(|limit| out.chars().count() >= limit)
1128                .unwrap_or(false)
1129            {
1130                break;
1131            }
1132            if !(chars.contains(&ch) ^ negated) {
1133                break;
1134            }
1135            self.pos += ch.len_utf8();
1136            out.push(ch);
1137        }
1138        if out.is_empty() {
1139            return Err(textscan_error_with(
1140                &TEXTSCAN_ERROR_PARSE,
1141                "textscan: character set conversion matched no characters",
1142            ));
1143        }
1144        Ok(out)
1145    }
1146
1147    fn is_at_separator(&self) -> bool {
1148        self.match_delimiter().is_some()
1149            || self
1150                .current_char()
1151                .map(|ch| ch.is_whitespace() || self.whitespace.contains(&ch))
1152                .unwrap_or(false)
1153    }
1154
1155    fn match_delimiter(&self) -> Option<&str> {
1156        self.delimiters
1157            .iter()
1158            .find(|delimiter| self.text[self.pos..].starts_with(delimiter.as_str()))
1159            .map(String::as_str)
1160    }
1161
1162    fn skip_comment(&mut self) -> bool {
1163        match &self.options.comment_style {
1164            CommentStyle::None => false,
1165            CommentStyle::Line(markers) => {
1166                if markers
1167                    .iter()
1168                    .any(|marker| !marker.is_empty() && self.text[self.pos..].starts_with(marker))
1169                {
1170                    while let Some(ch) = self.current_char() {
1171                        self.pos += ch.len_utf8();
1172                        if ch == '\n' {
1173                            break;
1174                        }
1175                    }
1176                    true
1177                } else {
1178                    false
1179                }
1180            }
1181            CommentStyle::Block { start, end } => {
1182                if start.is_empty() || !self.text[self.pos..].starts_with(start) {
1183                    return false;
1184                }
1185                let after_start = self.pos + start.len();
1186                if let Some(end_idx) = self.text[after_start..].find(end) {
1187                    self.pos = after_start + end_idx + end.len();
1188                } else {
1189                    self.pos = self.text.len();
1190                }
1191                true
1192            }
1193        }
1194    }
1195
1196    fn is_at_comment(&self) -> bool {
1197        match &self.options.comment_style {
1198            CommentStyle::None => false,
1199            CommentStyle::Line(markers) => markers
1200                .iter()
1201                .any(|marker| !marker.is_empty() && self.text[self.pos..].starts_with(marker)),
1202            CommentStyle::Block { start, .. } => {
1203                !start.is_empty() && self.text[self.pos..].starts_with(start)
1204            }
1205        }
1206    }
1207}
1208
1209#[derive(Debug, Clone)]
1210enum ParsedValue {
1211    Number(f64),
1212    Text(String),
1213}
1214
1215fn parse_float(token: &str) -> BuiltinResult<f64> {
1216    match token.trim().to_ascii_lowercase().as_str() {
1217        "" => Ok(f64::NAN),
1218        "nan" => Ok(f64::NAN),
1219        "inf" | "+inf" | "infinity" | "+infinity" => Ok(f64::INFINITY),
1220        "-inf" | "-infinity" => Ok(f64::NEG_INFINITY),
1221        _ => token.trim().parse::<f64>().map_err(|_| {
1222            textscan_error_with(
1223                &TEXTSCAN_ERROR_PARSE,
1224                format!("textscan: cannot parse '{token}' as a floating-point value"),
1225            )
1226        }),
1227    }
1228}
1229
1230fn parse_signed_int(token: &str) -> BuiltinResult<i64> {
1231    token.trim().parse::<i64>().map_err(|_| {
1232        textscan_error_with(
1233            &TEXTSCAN_ERROR_PARSE,
1234            format!("textscan: cannot parse '{token}' as an integer value"),
1235        )
1236    })
1237}
1238
1239fn parse_unsigned_int(token: &str) -> BuiltinResult<u64> {
1240    token.trim().parse::<u64>().map_err(|_| {
1241        textscan_error_with(
1242            &TEXTSCAN_ERROR_PARSE,
1243            format!("textscan: cannot parse '{token}' as an unsigned integer value"),
1244        )
1245    })
1246}
1247
1248fn build_output(columns: Vec<ColumnData>, options: &TextscanOptions) -> BuiltinResult<Value> {
1249    let values = if options.collect_output {
1250        collect_output(columns)?
1251    } else {
1252        columns
1253            .into_iter()
1254            .map(column_to_value)
1255            .collect::<BuiltinResult<Vec<_>>>()?
1256    };
1257    let len = values.len();
1258    CellArray::new(values, 1, len)
1259        .map(Value::Cell)
1260        .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_PARSE, format!("textscan: {err}")))
1261}
1262
1263fn collect_output(columns: Vec<ColumnData>) -> BuiltinResult<Vec<Value>> {
1264    let mut out = Vec::new();
1265    let mut idx = 0usize;
1266    while idx < columns.len() {
1267        if columns[idx].kind() == OutputKind::Numeric {
1268            let start = idx;
1269            while idx < columns.len() && columns[idx].kind() == OutputKind::Numeric {
1270                idx += 1;
1271            }
1272            out.push(numeric_group_to_value(&columns[start..idx])?);
1273        } else {
1274            out.push(column_to_value(columns[idx].clone())?);
1275            idx += 1;
1276        }
1277    }
1278    Ok(out)
1279}
1280
1281fn column_to_value(column: ColumnData) -> BuiltinResult<Value> {
1282    match column {
1283        ColumnData::Numeric(values) => Tensor::new(values.clone(), vec![values.len(), 1])
1284            .map(Value::Tensor)
1285            .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_PARSE, format!("textscan: {err}"))),
1286        ColumnData::Text(values) => cell_string_column(&values),
1287    }
1288}
1289
1290fn numeric_group_to_value(columns: &[ColumnData]) -> BuiltinResult<Value> {
1291    let rows = columns.first().map(ColumnData::len).unwrap_or(0);
1292    let cols = columns.len();
1293    let mut data = Vec::with_capacity(rows * cols);
1294    for column in columns {
1295        let ColumnData::Numeric(values) = column else {
1296            unreachable!("numeric group contains text column");
1297        };
1298        if values.len() != rows {
1299            return Err(textscan_error_with(
1300                &TEXTSCAN_ERROR_PARSE,
1301                "textscan: collected numeric columns have inconsistent lengths",
1302            ));
1303        }
1304        data.extend_from_slice(values);
1305    }
1306    Tensor::new(data, vec![rows, cols])
1307        .map(Value::Tensor)
1308        .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_PARSE, format!("textscan: {err}")))
1309}
1310
1311fn cell_string_column(values: &[String]) -> BuiltinResult<Value> {
1312    CellArray::new(
1313        values.iter().cloned().map(Value::String).collect(),
1314        values.len(),
1315        1,
1316    )
1317    .map(Value::Cell)
1318    .map_err(|err| textscan_error_with(&TEXTSCAN_ERROR_PARSE, format!("textscan: {err}")))
1319}
1320
1321fn string_scalar(value: &Value, context: &str) -> BuiltinResult<String> {
1322    match value {
1323        Value::String(s) => Ok(s.clone()),
1324        Value::CharArray(ca) if ca.rows == 1 => Ok(ca.data.iter().collect()),
1325        Value::StringArray(sa) if sa.data.len() == 1 => Ok(sa.data[0].clone()),
1326        _ => Err(textscan_error_with(
1327            &TEXTSCAN_ERROR_ARGUMENT,
1328            format!("textscan: expected {context} as a string scalar or character vector"),
1329        )),
1330    }
1331}
1332
1333fn string_list(value: &Value, context: &str) -> BuiltinResult<Vec<String>> {
1334    match value {
1335        Value::Cell(cell) => {
1336            let mut out = Vec::with_capacity(cell.data.len());
1337            for row in 0..cell.rows {
1338                for col in 0..cell.cols {
1339                    out.push(string_scalar(
1340                        &cell.get(row, col).map_err(|err| {
1341                            textscan_error_with(
1342                                &TEXTSCAN_ERROR_ARGUMENT,
1343                                format!("textscan: {err}"),
1344                            )
1345                        })?,
1346                        context,
1347                    )?);
1348                }
1349            }
1350            Ok(out)
1351        }
1352        Value::StringArray(sa) => Ok(sa.data.clone()),
1353        _ => Ok(vec![string_scalar(value, context)?]),
1354    }
1355}
1356
1357fn delimiter_list(value: &Value) -> BuiltinResult<Vec<String>> {
1358    let mut delimiters = string_list(value, "Delimiter")?;
1359    for delimiter in &mut delimiters {
1360        *delimiter = match delimiter.as_str() {
1361            "\\t" => "\t".to_string(),
1362            "\\n" => "\n".to_string(),
1363            "\\r" => "\r".to_string(),
1364            other => other.to_string(),
1365        };
1366        if delimiter.is_empty() {
1367            return Err(textscan_error_with(
1368                &TEXTSCAN_ERROR_ARGUMENT,
1369                "textscan: Delimiter entries must not be empty",
1370            ));
1371        }
1372    }
1373    Ok(delimiters)
1374}
1375
1376fn parse_comment_style(value: &Value) -> BuiltinResult<CommentStyle> {
1377    match value {
1378        Value::String(s) if s.eq_ignore_ascii_case("none") => Ok(CommentStyle::None),
1379        Value::CharArray(ca) if ca.rows == 1 => {
1380            let text: String = ca.data.iter().collect();
1381            if text.eq_ignore_ascii_case("none") {
1382                Ok(CommentStyle::None)
1383            } else {
1384                Ok(CommentStyle::Line(vec![text]))
1385            }
1386        }
1387        Value::Cell(cell) if cell.data.len() == 2 => {
1388            let first = string_scalar(
1389                &cell.get(0, 0).map_err(|err| {
1390                    textscan_error_with(&TEXTSCAN_ERROR_ARGUMENT, format!("textscan: {err}"))
1391                })?,
1392                "CommentStyle",
1393            )?;
1394            let second = if cell.rows == 1 {
1395                string_scalar(
1396                    &cell.get(0, 1).map_err(|err| {
1397                        textscan_error_with(&TEXTSCAN_ERROR_ARGUMENT, format!("textscan: {err}"))
1398                    })?,
1399                    "CommentStyle",
1400                )?
1401            } else {
1402                string_scalar(
1403                    &cell.get(1, 0).map_err(|err| {
1404                        textscan_error_with(&TEXTSCAN_ERROR_ARGUMENT, format!("textscan: {err}"))
1405                    })?,
1406                    "CommentStyle",
1407                )?
1408            };
1409            Ok(CommentStyle::Block {
1410                start: first,
1411                end: second,
1412            })
1413        }
1414        Value::Cell(cell) => {
1415            let mut markers = Vec::new();
1416            for row in 0..cell.rows {
1417                for col in 0..cell.cols {
1418                    markers.push(string_scalar(
1419                        &cell.get(row, col).map_err(|err| {
1420                            textscan_error_with(
1421                                &TEXTSCAN_ERROR_ARGUMENT,
1422                                format!("textscan: {err}"),
1423                            )
1424                        })?,
1425                        "CommentStyle",
1426                    )?);
1427                }
1428            }
1429            Ok(CommentStyle::Line(markers))
1430        }
1431        _ => {
1432            let text = string_scalar(value, "CommentStyle")?;
1433            if text.eq_ignore_ascii_case("none") {
1434                Ok(CommentStyle::None)
1435            } else {
1436                Ok(CommentStyle::Line(vec![text]))
1437            }
1438        }
1439    }
1440}
1441
1442fn bool_like(value: &Value, context: &str) -> BuiltinResult<bool> {
1443    match value {
1444        Value::Bool(value) => Ok(*value),
1445        Value::Num(value) if (*value - 0.0).abs() < f64::EPSILON => Ok(false),
1446        Value::Num(value) if (*value - 1.0).abs() < f64::EPSILON => Ok(true),
1447        Value::Int(value) if value.to_i64() == 0 => Ok(false),
1448        Value::Int(value) if value.to_i64() == 1 => Ok(true),
1449        _ => match string_scalar(value, context)?
1450            .trim()
1451            .to_ascii_lowercase()
1452            .as_str()
1453        {
1454            "true" | "on" | "yes" | "1" => Ok(true),
1455            "false" | "off" | "no" | "0" => Ok(false),
1456            _ => Err(textscan_error_with(
1457                &TEXTSCAN_ERROR_ARGUMENT,
1458                format!("textscan: {context} must be logical"),
1459            )),
1460        },
1461    }
1462}
1463
1464fn nonnegative_usize(value: &Value, context: &str) -> BuiltinResult<usize> {
1465    let raw = match value {
1466        Value::Num(value) => *value,
1467        Value::Int(value) => value.to_i64() as f64,
1468        Value::Tensor(tensor) if tensor.data.len() == 1 => tensor.data[0],
1469        _ => {
1470            return Err(textscan_error_with(
1471                &TEXTSCAN_ERROR_ARGUMENT,
1472                format!("textscan: {context} must be a nonnegative integer scalar"),
1473            ));
1474        }
1475    };
1476    if !raw.is_finite() || raw < 0.0 || raw.fract() != 0.0 {
1477        return Err(textscan_error_with(
1478            &TEXTSCAN_ERROR_ARGUMENT,
1479            format!("textscan: {context} must be a nonnegative integer scalar"),
1480        ));
1481    }
1482    Ok(raw as usize)
1483}
1484
1485fn numeric_fid(value: &Value) -> Option<i32> {
1486    let raw = match value {
1487        Value::Num(value) => *value,
1488        Value::Int(value) => value.to_i64() as f64,
1489        Value::Tensor(tensor) if tensor.data.len() == 1 => tensor.data[0],
1490        _ => return None,
1491    };
1492    if raw.is_finite() && raw.fract() == 0.0 && raw >= i32::MIN as f64 && raw <= i32::MAX as f64 {
1493        Some(raw as i32)
1494    } else {
1495        None
1496    }
1497}
1498
1499fn is_numeric_scalar(value: &Value) -> bool {
1500    numeric_fid(value).is_some()
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506    use futures::executor::block_on;
1507    use runmat_filesystem::OpenOptions;
1508    use std::sync::{Arc, Mutex as StdMutex};
1509
1510    use crate::builtins::io::filetext::registry::RegisteredFile;
1511
1512    fn output_cell(value: &Value) -> &CellArray {
1513        let Value::Cell(cell) = value else {
1514            panic!("expected cell array output");
1515        };
1516        cell
1517    }
1518
1519    fn output_value(value: &Value, col: usize) -> Value {
1520        output_cell(value).get(0, col).expect("output cell")
1521    }
1522
1523    fn numeric_column(value: &Value, col: usize) -> Vec<f64> {
1524        let Value::Tensor(tensor) = output_value(value, col) else {
1525            panic!("expected tensor");
1526        };
1527        tensor.data
1528    }
1529
1530    fn text_column(value: &Value, col: usize) -> Vec<String> {
1531        let Value::Cell(cell) = output_value(value, col) else {
1532            panic!("expected text cell column");
1533        };
1534        let mut out = Vec::new();
1535        for row in 0..cell.rows {
1536            let Value::String(text) = cell.get(row, 0).expect("text cell") else {
1537                panic!("expected string");
1538            };
1539            out.push(text);
1540        }
1541        out
1542    }
1543
1544    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1545    #[test]
1546    fn textscan_descriptor_covers_core_forms() {
1547        let labels: Vec<&str> = TEXTSCAN_DESCRIPTOR
1548            .signatures
1549            .iter()
1550            .map(|sig| sig.label)
1551            .collect();
1552        assert!(labels.contains(&"C = textscan(textOrFileID, formatSpec)"));
1553        assert!(labels.contains(&"C = textscan(textOrFileID, formatSpec, args...)"));
1554    }
1555
1556    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1557    #[test]
1558    fn textscan_reads_mixed_columns_from_text() {
1559        let out = block_on(textscan_builtin(
1560            Value::from("1 alpha\n2 beta\n"),
1561            Value::from("%f %s"),
1562            Vec::new(),
1563        ))
1564        .expect("textscan");
1565        assert_eq!(numeric_column(&out, 0), vec![1.0, 2.0]);
1566        assert_eq!(
1567            text_column(&out, 1),
1568            vec!["alpha".to_string(), "beta".to_string()]
1569        );
1570    }
1571
1572    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1573    #[test]
1574    fn textscan_honors_delimiter_header_comments_and_treat_empty() {
1575        let text = "# header\nA,1.5\nB,NA\n% ignored\nC,3.5\n";
1576        let out = block_on(textscan_builtin(
1577            Value::from(text),
1578            Value::from("%s %f"),
1579            vec![
1580                Value::from("Delimiter"),
1581                Value::from(","),
1582                Value::from("HeaderLines"),
1583                Value::Num(1.0),
1584                Value::from("CommentStyle"),
1585                Value::from("%"),
1586                Value::from("TreatAsEmpty"),
1587                Value::from("NA"),
1588            ],
1589        ))
1590        .expect("textscan");
1591        assert_eq!(
1592            text_column(&out, 0),
1593            vec!["A".to_string(), "B".to_string(), "C".to_string()]
1594        );
1595        let nums = numeric_column(&out, 1);
1596        assert_eq!(nums[0], 1.5);
1597        assert!(nums[1].is_nan());
1598        assert_eq!(nums[2], 3.5);
1599    }
1600
1601    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1602    #[test]
1603    fn textscan_supports_repeat_skip_collect_and_quotes() {
1604        let out = block_on(textscan_builtin(
1605            Value::from("1,drop,2,\"hello, world\"\n3,drop,4,\"tail\"\n"),
1606            Value::from("%f %*s %f %q"),
1607            vec![
1608                Value::Num(1.0),
1609                Value::from("Delimiter"),
1610                Value::from(","),
1611                Value::from("CollectOutput"),
1612                Value::Bool(true),
1613            ],
1614        ))
1615        .expect("textscan");
1616        let Value::Tensor(group) = output_value(&out, 0) else {
1617            panic!("expected collected numeric group");
1618        };
1619        assert_eq!(group.shape, vec![1, 2]);
1620        assert_eq!(group.data, vec![1.0, 2.0]);
1621        assert_eq!(text_column(&out, 1), vec!["hello, world".to_string()]);
1622    }
1623
1624    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1625    #[test]
1626    fn textscan_return_on_error_false_reports_parse_failure() {
1627        let err = block_on(textscan_builtin(
1628            Value::from("1\nbad\n"),
1629            Value::from("%f"),
1630            vec![Value::from("ReturnOnError"), Value::Bool(false)],
1631        ))
1632        .expect_err("parse failure");
1633        assert!(err.message().contains("cannot parse"));
1634    }
1635
1636    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1637    #[test]
1638    fn textscan_honors_literals_in_format_spec() {
1639        let out = block_on(textscan_builtin(
1640            Value::from("1,2\n3,4\n"),
1641            Value::from("%f,%f"),
1642            Vec::new(),
1643        ))
1644        .expect("textscan");
1645        assert_eq!(numeric_column(&out, 0), vec![1.0, 3.0]);
1646        assert_eq!(numeric_column(&out, 1), vec![2.0, 4.0]);
1647    }
1648
1649    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1650    #[test]
1651    fn textscan_width_and_char_conversions_leave_remainder() {
1652        let strings = block_on(textscan_builtin(
1653            Value::from("abcdef"),
1654            Value::from("%2s%s"),
1655            Vec::new(),
1656        ))
1657        .expect("textscan strings");
1658        assert_eq!(text_column(&strings, 0), vec!["ab".to_string()]);
1659        assert_eq!(text_column(&strings, 1), vec!["cdef".to_string()]);
1660
1661        let chars = block_on(textscan_builtin(
1662            Value::from("abc"),
1663            Value::from("%c%c%c"),
1664            Vec::new(),
1665        ))
1666        .expect("textscan chars");
1667        assert_eq!(text_column(&chars, 0), vec!["a".to_string()]);
1668        assert_eq!(text_column(&chars, 1), vec!["b".to_string()]);
1669        assert_eq!(text_column(&chars, 2), vec!["c".to_string()]);
1670    }
1671
1672    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1673    #[test]
1674    fn textscan_collect_output_preserves_column_major_numeric_group() {
1675        let out = block_on(textscan_builtin(
1676            Value::from("1 2\n3 4\n"),
1677            Value::from("%f %f"),
1678            vec![Value::from("CollectOutput"), Value::Bool(true)],
1679        ))
1680        .expect("textscan");
1681        let Value::Tensor(group) = output_value(&out, 0) else {
1682            panic!("expected collected numeric group");
1683        };
1684        assert_eq!(group.shape, vec![2, 2]);
1685        assert_eq!(group.data, vec![1.0, 3.0, 2.0, 4.0]);
1686    }
1687
1688    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1689    #[test]
1690    fn textscan_repeated_file_read_preserves_next_record_position() {
1691        let _guard = registry::test_guard();
1692        registry::reset_for_tests();
1693        let mut path = std::env::temp_dir();
1694        path.push("runmat_textscan_file_position.txt");
1695        std::fs::write(&path, "10 ten\n20 twenty\n").expect("write fixture");
1696
1697        let mut options = OpenOptions::new();
1698        options.read(true);
1699        let file = block_on(options.open_async(&path)).expect("open file");
1700        let handle = Arc::new(StdMutex::new(Some(file)));
1701        let fid = registry::register_file(RegisteredFile {
1702            path: path.clone(),
1703            permission: "r".to_string(),
1704            machinefmt: "native".to_string(),
1705            encoding: "UTF-8".to_string(),
1706            handle: handle.clone(),
1707        });
1708
1709        let out = block_on(textscan_builtin(
1710            Value::Num(fid as f64),
1711            Value::from("%f %s"),
1712            vec![Value::Num(1.0)],
1713        ))
1714        .expect("textscan");
1715        assert_eq!(numeric_column(&out, 0), vec![10.0]);
1716        assert_eq!(text_column(&out, 1), vec!["ten".to_string()]);
1717
1718        let mut remaining = String::new();
1719        let mut guard = handle.lock().expect("lock");
1720        let file = guard.as_mut().expect("file");
1721        std::io::Read::read_to_string(file, &mut remaining).expect("read remaining");
1722        assert_eq!(remaining, "20 twenty\n");
1723
1724        let _ = registry::close(fid);
1725        let _ = std::fs::remove_file(path);
1726        registry::reset_for_tests();
1727    }
1728
1729    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1730    #[test]
1731    fn textscan_shift_jis_registered_file_restores_source_byte_position() {
1732        shift_jis_registered_file_restores_source_byte_position(
1733            "shift_jis",
1734            &[
1735                b'1', b' ', 0x82, 0xA0, b'\n', b'2', b' ', b'n', b'e', b'x', b't', b'\n',
1736            ],
1737            "あ",
1738        );
1739    }
1740
1741    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1742    #[test]
1743    fn textscan_shift_jis_alias_restores_source_byte_position() {
1744        shift_jis_registered_file_restores_source_byte_position(
1745            "windows-31j",
1746            &[
1747                b'1', b' ', 0x82, 0xA0, b'\n', b'2', b' ', b'n', b'e', b'x', b't', b'\n',
1748            ],
1749            "あ",
1750        );
1751        assert!(is_shift_jis_encoding("ms932"));
1752        assert!(is_shift_jis_encoding("x-sjis"));
1753    }
1754
1755    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1756    #[test]
1757    fn textscan_shift_jis_0x80_advances_one_source_byte() {
1758        shift_jis_registered_file_restores_source_byte_position(
1759            "ms932",
1760            &[
1761                b'1', b' ', 0x80, b'\n', b'2', b' ', b'n', b'e', b'x', b't', b'\n',
1762            ],
1763            "\u{80}",
1764        );
1765    }
1766
1767    fn shift_jis_registered_file_restores_source_byte_position(
1768        encoding: &str,
1769        bytes: &[u8],
1770        expected_text: &str,
1771    ) {
1772        let _guard = registry::test_guard();
1773        registry::reset_for_tests();
1774        let mut path = std::env::temp_dir();
1775        path.push(format!(
1776            "runmat_textscan_shift_jis_position_{}.txt",
1777            encoding.replace('-', "_")
1778        ));
1779        std::fs::write(&path, bytes).expect("write fixture");
1780
1781        let mut options = OpenOptions::new();
1782        options.read(true);
1783        let file = block_on(options.open_async(&path)).expect("open file");
1784        let handle = Arc::new(StdMutex::new(Some(file)));
1785        let fid = registry::register_file(RegisteredFile {
1786            path: path.clone(),
1787            permission: "r".to_string(),
1788            machinefmt: "native".to_string(),
1789            encoding: encoding.to_string(),
1790            handle: handle.clone(),
1791        });
1792
1793        let out = block_on(textscan_builtin(
1794            Value::Num(fid as f64),
1795            Value::from("%f %s"),
1796            vec![Value::Num(1.0)],
1797        ))
1798        .expect("textscan");
1799        assert_eq!(numeric_column(&out, 0), vec![1.0]);
1800        assert_eq!(text_column(&out, 1), vec![expected_text.to_string()]);
1801
1802        let mut remaining = Vec::new();
1803        let mut guard = handle.lock().expect("lock");
1804        let file = guard.as_mut().expect("file");
1805        std::io::Read::read_to_end(file, &mut remaining).expect("read remaining");
1806        assert_eq!(remaining, b"2 next\n");
1807
1808        let _ = registry::close(fid);
1809        let _ = std::fs::remove_file(path);
1810        registry::reset_for_tests();
1811    }
1812
1813    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1814    #[test]
1815    fn textscan_rejects_standard_stream_identifiers() {
1816        let err = block_on(textscan_builtin(
1817            Value::Num(0.0),
1818            Value::from("%f"),
1819            Vec::new(),
1820        ))
1821        .expect_err("standard stream rejected");
1822        assert!(err.message().contains("standard input/output"));
1823    }
1824
1825    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1826    #[test]
1827    fn textscan_reads_from_registered_file_identifier() {
1828        let _guard = registry::test_guard();
1829        registry::reset_for_tests();
1830        let mut path = std::env::temp_dir();
1831        path.push("runmat_textscan_registered_file.txt");
1832        std::fs::write(&path, "skip\n10 ten\n20 twenty\n").expect("write fixture");
1833
1834        let mut options = OpenOptions::new();
1835        options.read(true);
1836        let file = block_on(options.open_async(&path)).expect("open file");
1837        let handle = Arc::new(StdMutex::new(Some(file)));
1838        let fid = registry::register_file(RegisteredFile {
1839            path: path.clone(),
1840            permission: "r".to_string(),
1841            machinefmt: "native".to_string(),
1842            encoding: "UTF-8".to_string(),
1843            handle,
1844        });
1845
1846        let out = block_on(textscan_builtin(
1847            Value::Num(fid as f64),
1848            Value::from("%f %s"),
1849            vec![Value::from("HeaderLines"), Value::Num(1.0)],
1850        ))
1851        .expect("textscan");
1852        assert_eq!(numeric_column(&out, 0), vec![10.0, 20.0]);
1853        assert_eq!(
1854            text_column(&out, 1),
1855            vec!["ten".to_string(), "twenty".to_string()]
1856        );
1857
1858        let _ = registry::close(fid);
1859        let _ = std::fs::remove_file(path);
1860        registry::reset_for_tests();
1861    }
1862}