1use 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}