Skip to main content

runmat_runtime/builtins/io/filetext/
fgets.rs

1//! MATLAB-compatible `fgets` builtin for RunMat.
2
3use std::io::{Read, Seek, SeekFrom};
4
5use encoding_rs::{Encoding, UTF_8};
6use runmat_builtins::{CharArray, Tensor, Value};
7use runmat_macros::runtime_builtin;
8
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ShapeRequirements,
12};
13use crate::builtins::io::filetext::registry;
14use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
15use runmat_filesystem::File;
16
17const INVALID_IDENTIFIER_MESSAGE: &str =
18    "Invalid file identifier. Use fopen to generate a valid file ID.";
19const BUILTIN_NAME: &str = "fgets";
20
21#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::io::filetext::fgets")]
22pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
23    name: "fgets",
24    op_kind: GpuOpKind::Custom("file-io"),
25    supported_precisions: &[],
26    broadcast: BroadcastSemantics::None,
27    provider_hooks: &[],
28    constant_strategy: ConstantStrategy::InlineLiteral,
29    residency: ResidencyPolicy::GatherImmediately,
30    nan_mode: ReductionNaN::Include,
31    two_pass_threshold: None,
32    workgroup_size: None,
33    accepts_nan_mode: false,
34    notes: "Host-only file I/O; arguments gathered from the GPU when necessary.",
35};
36
37fn fgets_error(message: impl Into<String>) -> RuntimeError {
38    build_runtime_error(message)
39        .with_builtin(BUILTIN_NAME)
40        .build()
41}
42
43fn map_control_flow(err: RuntimeError) -> RuntimeError {
44    let message = err.message().to_string();
45    let identifier = err.identifier().map(|value| value.to_string());
46    let mut builder = build_runtime_error(format!("{BUILTIN_NAME}: {message}"))
47        .with_builtin(BUILTIN_NAME)
48        .with_source(err);
49    if let Some(identifier) = identifier {
50        builder = builder.with_identifier(identifier);
51    }
52    builder.build()
53}
54
55#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::io::filetext::fgets")]
56pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
57    name: "fgets",
58    shape: ShapeRequirements::Any,
59    constant_strategy: ConstantStrategy::InlineLiteral,
60    elementwise: None,
61    reduction: None,
62    emits_nan: false,
63    notes: "File I/O calls are not eligible for fusion.",
64};
65
66#[runtime_builtin(
67    name = "fgets",
68    category = "io/filetext",
69    summary = "Read the next line from a file, including newline characters.",
70    keywords = "fgets,file,io,line,newline",
71    accel = "cpu",
72    type_resolver(crate::builtins::io::type_resolvers::fgets_type),
73    builtin_path = "crate::builtins::io::filetext::fgets"
74)]
75async fn fgets_builtin(fid: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
76    let eval = evaluate(&fid, &rest).await?;
77    if let Some(out_count) = crate::output_count::current_output_count() {
78        if out_count == 0 {
79            return Ok(Value::OutputList(Vec::new()));
80        }
81        return Ok(crate::output_count::output_list_with_padding(
82            out_count,
83            eval.outputs(),
84        ));
85    }
86    Ok(eval.first_output())
87}
88
89#[derive(Clone, Debug)]
90pub struct FgetsEval {
91    line: Value,
92    terminators: Value,
93}
94
95impl FgetsEval {
96    fn new(line: Value, terminators: Value) -> Self {
97        Self { line, terminators }
98    }
99
100    fn end_of_file() -> Self {
101        Self {
102            line: Value::Num(-1.0),
103            terminators: Value::Num(-1.0),
104        }
105    }
106
107    pub fn first_output(&self) -> Value {
108        self.line.clone()
109    }
110
111    pub fn outputs(&self) -> Vec<Value> {
112        vec![self.line.clone(), self.terminators.clone()]
113    }
114}
115
116pub async fn evaluate(fid_value: &Value, rest: &[Value]) -> BuiltinResult<FgetsEval> {
117    if rest.len() > 1 {
118        return Err(fgets_error("fgets: too many input arguments"));
119    }
120
121    let fid_host = gather_value(fid_value).await?;
122    let fid = parse_fid(&fid_host)?;
123    if fid < 0 {
124        return Err(fgets_error("fgets: file identifier must be non-negative"));
125    }
126    if fid < 3 {
127        return Err(fgets_error(
128            "fgets: standard input/output identifiers are not supported yet",
129        ));
130    }
131
132    let info = registry::info_for(fid)
133        .ok_or_else(|| fgets_error(format!("fgets: {INVALID_IDENTIFIER_MESSAGE}")))?;
134    if !permission_allows_read(&info.permission) {
135        return Err(fgets_error(
136            "fgets: file identifier is not open for reading",
137        ));
138    }
139    let handle = registry::take_handle(fid)
140        .ok_or_else(|| fgets_error(format!("fgets: {INVALID_IDENTIFIER_MESSAGE}")))?;
141    let mut file = handle
142        .lock()
143        .map_err(|_| fgets_error("fgets: failed to lock file handle (poisoned mutex)"))?;
144
145    let nchar_limit = parse_nchar(rest).await?;
146    let max_bytes = apply_matlab_nchar_limit(nchar_limit);
147    let read = read_line(&mut file, max_bytes)?;
148    if read.eof_before_any {
149        return Ok(FgetsEval::end_of_file());
150    }
151
152    let encoding = if info.encoding.trim().is_empty() {
153        "UTF-8".to_string()
154    } else {
155        info.encoding.clone()
156    };
157
158    let line_value = bytes_to_char_array(&read.data, &encoding)?;
159    let terminators_value = if read.terminators.is_empty() {
160        empty_numeric_row()
161    } else {
162        numeric_row(&read.terminators)?
163    };
164
165    Ok(FgetsEval::new(line_value, terminators_value))
166}
167
168async fn gather_value(value: &Value) -> BuiltinResult<Value> {
169    gather_if_needed_async(value)
170        .await
171        .map_err(map_control_flow)
172}
173
174fn parse_fid(value: &Value) -> BuiltinResult<i32> {
175    match value {
176        Value::Num(n) => {
177            if !n.is_finite() {
178                return Err(fgets_error("fgets: file identifier must be finite"));
179            }
180            if (n.fract()).abs() > f64::EPSILON {
181                return Err(fgets_error(
182                    "fgets: file identifier must be an integer scalar",
183                ));
184            }
185            Ok(*n as i32)
186        }
187        Value::Int(i) => Ok(i.to_i64() as i32),
188        Value::Tensor(t) if t.data.len() == 1 => {
189            let n = t.data[0];
190            if !n.is_finite() {
191                return Err(fgets_error("fgets: file identifier must be finite"));
192            }
193            if (n.fract()).abs() > f64::EPSILON {
194                return Err(fgets_error(
195                    "fgets: file identifier must be an integer scalar",
196                ));
197            }
198            Ok(n as i32)
199        }
200        _ => Err(fgets_error(
201            "fgets: file identifier must be a numeric scalar",
202        )),
203    }
204}
205
206async fn parse_nchar(args: &[Value]) -> BuiltinResult<Option<usize>> {
207    if args.is_empty() {
208        return Ok(None);
209    }
210    let value = gather_value(&args[0]).await?;
211    match value {
212        Value::Num(n) => {
213            if !n.is_finite() {
214                if n.is_sign_positive() {
215                    return Ok(None);
216                }
217                return Err(fgets_error(
218                    "fgets: nchar must be a non-negative integer scalar",
219                ));
220            }
221            if n < 0.0 {
222                return Err(fgets_error(
223                    "fgets: nchar must be a non-negative integer scalar",
224                ));
225            }
226            if (n.fract()).abs() > f64::EPSILON {
227                return Err(fgets_error(
228                    "fgets: nchar must be a non-negative integer scalar",
229                ));
230            }
231            Ok(Some(n as usize))
232        }
233        Value::Int(i) => {
234            let raw = i.to_i64();
235            if raw < 0 {
236                return Err(fgets_error(
237                    "fgets: nchar must be a non-negative integer scalar",
238                ));
239            }
240            Ok(Some(raw as usize))
241        }
242        Value::Tensor(t) if t.data.len() == 1 => {
243            let n = t.data[0];
244            if !n.is_finite() {
245                if n.is_sign_positive() {
246                    return Ok(None);
247                }
248                return Err(fgets_error(
249                    "fgets: nchar must be a non-negative integer scalar",
250                ));
251            }
252            if n < 0.0 {
253                return Err(fgets_error(
254                    "fgets: nchar must be a non-negative integer scalar",
255                ));
256            }
257            if (n.fract()).abs() > f64::EPSILON {
258                return Err(fgets_error(
259                    "fgets: nchar must be a non-negative integer scalar",
260                ));
261            }
262            Ok(Some(n as usize))
263        }
264        _ => Err(fgets_error(
265            "fgets: nchar must be a non-negative integer scalar",
266        )),
267    }
268}
269
270fn permission_allows_read(permission: &str) -> bool {
271    let lower = permission.to_ascii_lowercase();
272    lower.starts_with('r') || lower.contains('+')
273}
274
275fn apply_matlab_nchar_limit(nchar_limit: Option<usize>) -> Option<usize> {
276    nchar_limit.map(|nchar| nchar.saturating_sub(1))
277}
278
279struct LineRead {
280    data: Vec<u8>,
281    terminators: Vec<u8>,
282    eof_before_any: bool,
283}
284
285fn read_line(file: &mut File, limit: Option<usize>) -> BuiltinResult<LineRead> {
286    let mut data = Vec::new();
287    let mut terminators = Vec::new();
288    let mut eof_before_any = false;
289
290    let max_bytes = limit.unwrap_or(usize::MAX);
291    if max_bytes == 0 {
292        return Ok(LineRead {
293            data,
294            terminators,
295            eof_before_any,
296        });
297    }
298
299    let mut first_attempt = true;
300    let mut buffer = [0u8; 1];
301    loop {
302        if data.len() >= max_bytes {
303            break;
304        }
305
306        let read = file.read(&mut buffer).map_err(|err| {
307            build_runtime_error(format!("fgets: failed to read from file: {err}"))
308                .with_builtin(BUILTIN_NAME)
309                .with_source(err)
310                .build()
311        })?;
312        if read == 0 {
313            if data.is_empty() && first_attempt {
314                eof_before_any = true;
315            }
316            break;
317        }
318        first_attempt = false;
319        let byte = buffer[0];
320
321        if byte == b'\n' {
322            if data.len().saturating_add(1) > max_bytes {
323                file.seek(SeekFrom::Current(-1)).map_err(|err| {
324                    build_runtime_error(format!("fgets: failed to seek in file: {err}"))
325                        .with_builtin(BUILTIN_NAME)
326                        .with_source(err)
327                        .build()
328                })?;
329            } else {
330                data.push(b'\n');
331                terminators.push(b'\n');
332            }
333            break;
334        } else if byte == b'\r' {
335            let mut newline = [0u8; 2];
336            newline[0] = b'\r';
337            let mut newline_len = 1usize;
338            let mut consumed = 1i64;
339
340            let mut next = [0u8; 1];
341            let read_next = file.read(&mut next).map_err(|err| {
342                build_runtime_error(format!("fgets: failed to read from file: {err}"))
343                    .with_builtin(BUILTIN_NAME)
344                    .with_source(err)
345                    .build()
346            })?;
347            if read_next > 0 {
348                if next[0] == b'\n' {
349                    newline[1] = b'\n';
350                    newline_len = 2;
351                    consumed = 2;
352                } else {
353                    file.seek(SeekFrom::Current(-1)).map_err(|err| {
354                        build_runtime_error(format!("fgets: failed to seek in file: {err}"))
355                            .with_builtin(BUILTIN_NAME)
356                            .with_source(err)
357                            .build()
358                    })?;
359                }
360            }
361
362            if data.len().saturating_add(newline_len) > max_bytes {
363                file.seek(SeekFrom::Current(-consumed)).map_err(|err| {
364                    build_runtime_error(format!("fgets: failed to seek in file: {err}"))
365                        .with_builtin(BUILTIN_NAME)
366                        .with_source(err)
367                        .build()
368                })?;
369            } else {
370                data.extend_from_slice(&newline[..newline_len]);
371                terminators.extend_from_slice(&newline[..newline_len]);
372            }
373            break;
374        } else {
375            data.push(byte);
376        }
377    }
378
379    Ok(LineRead {
380        data,
381        terminators,
382        eof_before_any,
383    })
384}
385
386fn bytes_to_char_array(bytes: &[u8], encoding: &str) -> BuiltinResult<Value> {
387    let chars = decode_bytes(bytes, encoding)?;
388    let cols = chars.len();
389    let char_array = CharArray::new(chars, 1, cols)
390        .map_err(|e| fgets_error(format!("fgets: failed to build char array: {e}")))?;
391    Ok(Value::CharArray(char_array))
392}
393
394fn decode_bytes(bytes: &[u8], encoding: &str) -> BuiltinResult<Vec<char>> {
395    let label = encoding.trim();
396    if label.is_empty() || label.eq_ignore_ascii_case("utf-8") || label.eq_ignore_ascii_case("utf8")
397    {
398        return decode_with_encoding(bytes, UTF_8);
399    }
400    if label.eq_ignore_ascii_case("binary") {
401        return Ok(bytes
402            .iter()
403            .map(|&b| char::from_u32(b as u32).unwrap())
404            .collect());
405    }
406    if label.eq_ignore_ascii_case("latin1")
407        || label.eq_ignore_ascii_case("latin-1")
408        || label.eq_ignore_ascii_case("iso-8859-1")
409    {
410        return Ok(bytes
411            .iter()
412            .map(|&b| char::from_u32(b as u32).unwrap())
413            .collect());
414    }
415    if label.eq_ignore_ascii_case("windows-1252") || label.eq_ignore_ascii_case("cp1252") {
416        return decode_with_encoding(bytes, encoding_rs::WINDOWS_1252);
417    }
418    if label.eq_ignore_ascii_case("shift_jis")
419        || label.eq_ignore_ascii_case("shift-jis")
420        || label.eq_ignore_ascii_case("sjis")
421    {
422        return decode_with_encoding(bytes, encoding_rs::SHIFT_JIS);
423    }
424    if label.eq_ignore_ascii_case("us-ascii")
425        || label.eq_ignore_ascii_case("ascii")
426        || label.eq_ignore_ascii_case("us_ascii")
427        || label.eq_ignore_ascii_case("usascii")
428    {
429        return decode_ascii(bytes);
430    }
431    if label.eq_ignore_ascii_case("system") {
432        let fallback = system_default_encoding_label();
433        if fallback.eq_ignore_ascii_case("binary") {
434            return Ok(bytes
435                .iter()
436                .map(|&b| char::from_u32(b as u32).unwrap())
437                .collect());
438        }
439        return decode_bytes(bytes, fallback);
440    }
441
442    if let Some(enc) = Encoding::for_label(label.as_bytes()) {
443        return decode_with_encoding(bytes, enc);
444    }
445
446    Err(fgets_error(format!(
447        "fgets: unsupported encoding '{encoding}'"
448    )))
449}
450
451fn decode_with_encoding(bytes: &[u8], enc: &'static Encoding) -> BuiltinResult<Vec<char>> {
452    let (cow, _, had_errors) = enc.decode(bytes);
453    if had_errors {
454        return Err(fgets_error(format!(
455            "fgets: unable to decode bytes using encoding '{}'",
456            enc.name()
457        )));
458    }
459    Ok(cow.chars().collect())
460}
461
462fn decode_ascii(bytes: &[u8]) -> BuiltinResult<Vec<char>> {
463    if let Some(byte) = bytes.iter().find(|&&b| b > 0x7F) {
464        return Err(fgets_error(format!(
465            "fgets: byte value {} is outside the ASCII range",
466            byte
467        )));
468    }
469    Ok(bytes
470        .iter()
471        .map(|&b| char::from_u32(b as u32).unwrap())
472        .collect())
473}
474
475fn numeric_row(bytes: &[u8]) -> BuiltinResult<Value> {
476    let data: Vec<f64> = bytes.iter().map(|&b| b as f64).collect();
477    let tensor = Tensor::new(data, vec![1, bytes.len()])
478        .map_err(|e| fgets_error(format!("fgets: failed to construct numeric array: {e}")))?;
479    Ok(Value::Tensor(tensor))
480}
481
482fn empty_numeric_row() -> Value {
483    let tensor = Tensor::new(Vec::new(), vec![0, 0]).unwrap_or_else(|_| Tensor::zeros(vec![0, 0]));
484    Value::Tensor(tensor)
485}
486
487fn system_default_encoding_label() -> &'static str {
488    #[cfg(windows)]
489    {
490        "windows-1252"
491    }
492    #[cfg(not(windows))]
493    {
494        "utf-8"
495    }
496}
497
498#[cfg(test)]
499pub(crate) mod tests {
500    use super::*;
501    use crate::builtins::common::test_support;
502    use crate::builtins::io::filetext::{fopen, registry};
503    use crate::RuntimeError;
504    use runmat_accelerate_api::HostTensorView;
505    use runmat_builtins::IntValue;
506    use runmat_time::system_time_now;
507    use std::path::{Path, PathBuf};
508    use std::time::UNIX_EPOCH;
509
510    fn unwrap_error_message(err: RuntimeError) -> String {
511        err.message().to_string()
512    }
513
514    fn run_evaluate(fid_value: &Value, rest: &[Value]) -> BuiltinResult<FgetsEval> {
515        futures::executor::block_on(evaluate(fid_value, rest))
516    }
517
518    fn run_fopen(args: &[Value]) -> BuiltinResult<fopen::FopenEval> {
519        futures::executor::block_on(fopen::evaluate(args))
520    }
521
522    fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
523        registry::test_guard()
524    }
525
526    fn unique_path(prefix: &str) -> PathBuf {
527        let now = system_time_now()
528            .duration_since(UNIX_EPOCH)
529            .expect("time went backwards");
530        let filename = format!("{}_{}_{}.tmp", prefix, now.as_secs(), now.subsec_nanos());
531        std::env::temp_dir().join(filename)
532    }
533
534    fn fopen_path(path: &Path) -> FopenHandle {
535        let eval = run_fopen(&[Value::from(path.to_string_lossy().to_string())]).expect("fopen");
536        let open = eval.as_open().expect("open outputs");
537        assert!(open.fid >= 3.0);
538        FopenHandle {
539            fid: open.fid as i32,
540        }
541    }
542
543    struct FopenHandle {
544        fid: i32,
545    }
546
547    impl Drop for FopenHandle {
548        fn drop(&mut self) {
549            let _ = registry::close(self.fid);
550        }
551    }
552
553    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
554    #[test]
555    fn fgets_reads_line_with_newline() {
556        let _guard = registry_guard();
557        registry::reset_for_tests();
558        let path = unique_path("fgets_line");
559        test_support::fs::write(&path, "Hello world\nSecond line\n").unwrap();
560
561        let handle = fopen_path(&path);
562        let eval = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("fgets");
563        let line = eval.first_output();
564        match line {
565            Value::CharArray(ca) => {
566                let text: String = ca.data.iter().collect();
567                assert_eq!(text, "Hello world\n");
568            }
569            other => panic!("expected char array, got {other:?}"),
570        }
571        let ltout = eval.outputs()[1].clone();
572        match ltout {
573            Value::Tensor(t) => {
574                assert_eq!(t.data, vec![10.0]);
575                assert_eq!(t.shape, vec![1, 1]);
576            }
577            other => panic!("expected numeric tensor, got {other:?}"),
578        }
579
580        test_support::fs::remove_file(&path).unwrap();
581    }
582
583    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
584    #[test]
585    fn fgets_returns_minus_one_at_eof() {
586        let _guard = registry_guard();
587        registry::reset_for_tests();
588        let path = unique_path("fgets_eof");
589        test_support::fs::write(&path, "line\n").unwrap();
590        let handle = fopen_path(&path);
591
592        let _ = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("first read");
593        let eval = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("second read");
594        assert_eq!(eval.first_output(), Value::Num(-1.0));
595        assert_eq!(eval.outputs()[1], Value::Num(-1.0));
596
597        test_support::fs::remove_file(&path).unwrap();
598    }
599
600    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
601    #[test]
602    fn fgets_honours_nchar_limit() {
603        let _guard = registry_guard();
604        registry::reset_for_tests();
605        let path = unique_path("fgets_limit");
606        test_support::fs::write(&path, "abcdefghij\nrest\n").unwrap();
607        let handle = fopen_path(&path);
608
609        let eval =
610            run_evaluate(&Value::Num(handle.fid as f64), &[Value::Num(5.0)]).expect("limited read");
611        match eval.first_output() {
612            Value::CharArray(ca) => {
613                let text: String = ca.data.iter().collect();
614                assert_eq!(text, "abcd");
615            }
616            other => panic!("expected char array, got {other:?}"),
617        }
618        match &eval.outputs()[1] {
619            Value::Tensor(t) => {
620                assert!(t.data.is_empty());
621            }
622            other => panic!("expected empty numeric tensor, got {other:?}"),
623        }
624
625        test_support::fs::remove_file(&path).unwrap();
626    }
627
628    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
629    #[test]
630    fn fgets_errors_for_write_only_identifier() {
631        let _guard = registry_guard();
632        registry::reset_for_tests();
633        let path = unique_path("fgets_write_only");
634        test_support::fs::write(&path, "payload").unwrap();
635        let eval = run_fopen(&[
636            Value::from(path.to_string_lossy().to_string()),
637            Value::from("w"),
638        ])
639        .expect("fopen");
640        let open = eval.as_open().expect("open outputs");
641        assert!(open.fid >= 3.0);
642        let err = unwrap_error_message(run_evaluate(&Value::Num(open.fid), &[]).unwrap_err());
643        assert_eq!(err, "fgets: file identifier is not open for reading");
644        test_support::fs::remove_file(&path).unwrap();
645    }
646
647    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
648    #[test]
649    fn fgets_respects_limit_before_crlf_sequence() {
650        let _guard = registry_guard();
651        registry::reset_for_tests();
652        let path = unique_path("fgets_limit_crlf");
653        test_support::fs::write(&path, b"ABCDE\r\nnext\n").unwrap();
654        let handle = fopen_path(&path);
655
656        let first =
657            run_evaluate(&Value::Num(handle.fid as f64), &[Value::Num(3.0)]).expect("first");
658        match first.first_output() {
659            Value::CharArray(ca) => {
660                let text: String = ca.data.iter().collect();
661                assert_eq!(text, "AB");
662            }
663            other => panic!("expected char array, got {other:?}"),
664        }
665        match &first.outputs()[1] {
666            Value::Tensor(t) => assert!(t.data.is_empty()),
667            other => panic!("expected empty numeric tensor, got {other:?}"),
668        }
669
670        let second = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("second");
671        match second.first_output() {
672            Value::CharArray(ca) => {
673                let text: String = ca.data.iter().collect();
674                assert_eq!(text, "CDE\r\n");
675            }
676            other => panic!("expected char array, got {other:?}"),
677        }
678        match &second.outputs()[1] {
679            Value::Tensor(t) => assert_eq!(t.data, vec![13.0, 10.0]),
680            other => panic!("expected CRLF terminators, got {other:?}"),
681        }
682
683        let third = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("third");
684        match third.first_output() {
685            Value::CharArray(ca) => {
686                let text: String = ca.data.iter().collect();
687                assert_eq!(text, "next\n");
688            }
689            other => panic!("expected char array, got {other:?}"),
690        }
691
692        test_support::fs::remove_file(&path).unwrap();
693    }
694
695    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
696    #[test]
697    fn fgets_handles_crlf_newlines() {
698        let _guard = registry_guard();
699        registry::reset_for_tests();
700        let path = unique_path("fgets_crlf");
701        test_support::fs::write(&path, b"first line\r\nsecond\r\n").unwrap();
702        let handle = fopen_path(&path);
703
704        let eval = run_evaluate(&Value::Num(handle.fid as f64), &[]).expect("fgets");
705        let outputs = eval.outputs();
706        match &outputs[0] {
707            Value::CharArray(ca) => {
708                let text: String = ca.data.iter().collect();
709                assert_eq!(text, "first line\r\n");
710            }
711            other => panic!("expected char array, got {other:?}"),
712        }
713        match &outputs[1] {
714            Value::Tensor(t) => {
715                assert_eq!(t.data, vec![13.0, 10.0]);
716            }
717            other => panic!("expected numeric tensor, got {other:?}"),
718        }
719
720        test_support::fs::remove_file(&path).unwrap();
721    }
722
723    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
724    #[test]
725    fn fgets_decodes_latin1() {
726        let _guard = registry_guard();
727        registry::reset_for_tests();
728        let path = unique_path("fgets_latin1");
729        test_support::fs::write(&path, [0x48u8, 0x6f, 0x6c, 0x61, 0x20, 0xf1, b'\n']).unwrap();
730        let eval = run_fopen(&[
731            Value::from(path.to_string_lossy().to_string()),
732            Value::from("r"),
733            Value::from("native"),
734            Value::from("latin1"),
735        ])
736        .expect("fopen");
737        let open = eval.as_open().expect("open outputs");
738        let fid = open.fid as i32;
739
740        let read = run_evaluate(&Value::Num(fid as f64), &[]).expect("fgets");
741        match read.first_output() {
742            Value::CharArray(ca) => {
743                let text: String = ca.data.iter().collect();
744                assert_eq!(text, "Hola ñ\n");
745            }
746            other => panic!("expected char array, got {other:?}"),
747        }
748
749        let _ = registry::close(fid);
750        test_support::fs::remove_file(&path).unwrap();
751    }
752
753    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
754    #[test]
755    fn fgets_nchar_zero_returns_empty_char() {
756        let _guard = registry_guard();
757        registry::reset_for_tests();
758        let path = unique_path("fgets_zero");
759        test_support::fs::write(&path, "hello\n").unwrap();
760        let handle = fopen_path(&path);
761
762        let eval = run_evaluate(
763            &Value::Num(handle.fid as f64),
764            &[Value::Int(IntValue::I32(0))],
765        )
766        .expect("fgets");
767        match eval.first_output() {
768            Value::CharArray(ca) => {
769                assert_eq!(ca.rows, 1);
770                assert_eq!(ca.cols, 0);
771                assert!(ca.data.is_empty());
772            }
773            other => panic!("expected empty char array, got {other:?}"),
774        }
775
776        test_support::fs::remove_file(&path).unwrap();
777    }
778
779    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
780    #[test]
781    fn fgets_nchar_one_returns_empty_char() {
782        let _guard = registry_guard();
783        registry::reset_for_tests();
784        let path = unique_path("fgets_one");
785        test_support::fs::write(&path, "hello\n").unwrap();
786        let handle = fopen_path(&path);
787
788        let eval = run_evaluate(
789            &Value::Num(handle.fid as f64),
790            &[Value::Int(IntValue::I32(1))],
791        )
792        .expect("fgets");
793        match eval.first_output() {
794            Value::CharArray(ca) => {
795                assert_eq!(ca.rows, 1);
796                assert_eq!(ca.cols, 0);
797                assert!(ca.data.is_empty());
798            }
799            other => panic!("expected empty char array, got {other:?}"),
800        }
801
802        test_support::fs::remove_file(&path).unwrap();
803    }
804
805    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
806    #[test]
807    fn fgets_gathers_gpu_scalar_arguments() {
808        let _guard = registry_guard();
809        registry::reset_for_tests();
810        let path = unique_path("fgets_gpu_args");
811        test_support::fs::write(&path, b"abcdef\nextra").unwrap();
812        let handle = fopen_path(&path);
813
814        test_support::with_test_provider(|provider| {
815            let fid_host = [handle.fid as f64];
816            let fid_view = HostTensorView {
817                data: &fid_host,
818                shape: &[1, 1],
819            };
820            let fid_gpu = Value::GpuTensor(provider.upload(&fid_view).expect("upload fid"));
821
822            let limit_host = [3.0f64];
823            let limit_view = HostTensorView {
824                data: &limit_host,
825                shape: &[1, 1],
826            };
827            let limit_gpu = Value::GpuTensor(provider.upload(&limit_view).expect("upload limit"));
828
829            let eval = run_evaluate(&fid_gpu, &[limit_gpu]).expect("fgets");
830            match eval.first_output() {
831                Value::CharArray(ca) => {
832                    let text: String = ca.data.iter().collect();
833                    assert_eq!(text, "ab");
834                }
835                other => panic!("expected char array, got {other:?}"),
836            }
837        });
838
839        test_support::fs::remove_file(&path).unwrap();
840    }
841}