Skip to main content

lua_stdlib/
io_lib.rs

1//! Standard I/O library — `io.*` functions and `file:*` methods.
2//!
3//! C source: `src/liolib.c` (841 lines, ~35 functions).
4//!
5//! PORT NOTE: Filesystem and process access is host-provided. Regular files use
6//! `GlobalState::file_open_hook`, `io.popen` uses `GlobalState::popen_hook`, and
7//! stdout/stderr use output hooks when installed. The native CLI provides hooks
8//! backed by `std::fs`, `std::process`, and `std::io`; sandboxed and WASM hosts
9//! can leave those capabilities absent.
10//!
11//! PORT NOTE: Rust's borrow checker prevents holding `&mut dyn LuaFileOps`
12//! (extracted from userdata) and `&mut LuaState` simultaneously. The affected
13//! functions (`io_read`, `f_read`, `io_write`, `f_write`, `io_flush`, `f_flush`,
14//! `f_seek`, `f_setvbuf`, `get_io_file`) are marked with `TODO(port): borrow
15//! split`. Phase B must restructure `g_read`/`g_write` to take a `StackIdx`
16//! rather than a raw `&mut dyn LuaFileOps`, and use `RefCell` inside `LStream`
17//! for interior mutability, or extract the file handle via a separate borrow
18//! scope.
19
20use std::cell::RefCell;
21use std::collections::HashMap;
22use std::io::{self, SeekFrom};
23use std::rc::Rc;
24
25use crate::state_stub::{LuaState, LuaStateStubExt as _};
26use lua_types::{LuaError, LuaFileHandle, LuaType, LuaValue};
27use lua_vm::state::{InputHook, OutputHook};
28
29thread_local! {
30    /// Side-table mapping userdata identity (the `Rc` pointer address from
31    /// `GcRef::identity()`) to its associated `LStream`. The C port stores
32    /// `LStream` directly inside the userdata payload; Rust cannot do that
33    /// safely because `LStream` carries heap pointers (a `Box<dyn LuaFileOps>`
34    /// and a fn pointer). Entries are inserted by `new_pre_file` and never
35    /// removed in Phase A-C — leak is intentional per `PORTING.md` §2 #4.
36    static LSTREAM_REGISTRY: RefCell<HashMap<usize, Rc<RefCell<LStream>>>>
37        = RefCell::new(HashMap::new());
38}
39
40fn register_lstream(ud_id: usize, lstream: LStream) -> Rc<RefCell<LStream>> {
41    let cell = Rc::new(RefCell::new(lstream));
42    LSTREAM_REGISTRY.with(|reg| {
43        reg.borrow_mut().insert(ud_id, cell.clone());
44    });
45    cell
46}
47
48fn lookup_lstream(ud_id: usize) -> Option<Rc<RefCell<LStream>>> {
49    LSTREAM_REGISTRY.with(|reg| reg.borrow().get(&ud_id).cloned())
50}
51
52// ── Constants ────────────────────────────────────────────────────────────────
53
54/// Name of the file-handle metatable in the Lua registry. C: `LUA_FILEHANDLE`.
55pub const LUA_FILE_HANDLE: &[u8] = b"FILE*";
56
57/// Registry key for the default input file. C: `IO_INPUT` = `"_IO_input"`.
58const IO_INPUT_KEY: &[u8] = b"_IO_input";
59
60/// Registry key for the default output file. C: `IO_OUTPUT` = `"_IO_output"`.
61const IO_OUTPUT_KEY: &[u8] = b"_IO_output";
62
63/// Number of bytes in the `"_IO_"` prefix, used to strip it in error messages.
64const IO_PREFIX_LEN: usize = 4;
65
66/// Maximum number of format-arguments passed to `file:lines`. C: `MAXARGLINE`.
67const MAX_ARG_LINE: usize = 250;
68
69/// Maximum byte-length of a numeric literal read from a file. C: `L_MAXLENNUM`.
70const L_MAX_LEN_NUM: usize = 200;
71
72/// End-of-file sentinel returned by `LuaFileOps::read_byte`. C: `EOF` == -1.
73const EOF_SENTINEL: i32 = -1;
74
75/// Bulk-read chunk size, mirroring C's `LUAL_BUFFERSIZE`.
76const LUAL_BUFFER_SIZE: usize = 8192;
77
78// ── Traits ───────────────────────────────────────────────────────────────────
79
80/// Capabilities required by the io library from an OS file handle.
81///
82/// This trait extends [`LuaFileHandle`] (defined in `lua-types`) with the
83/// additional `set_buf_mode` operation. Concrete implementations backed by
84/// `std::fs::File` live in `lua-cli`; standard-stream implementations live in
85/// this module. The split keeps `std::fs` out of `lua-stdlib` per PORTING.md §1.
86pub trait LuaFileOps: LuaFileHandle {
87    /// Control stream buffering. C: `setvbuf`.
88    fn set_buf_mode(&mut self, mode: BufMode, size: usize) -> io::Result<()>;
89}
90
91// ── Enums ────────────────────────────────────────────────────────────────────
92
93/// Seek anchor for `file:seek`. C: `{SEEK_SET, SEEK_CUR, SEEK_END}`.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum SeekWhence {
96    Set,
97    Cur,
98    End,
99}
100
101/// Buffering mode for `file:setvbuf`. C: `{_IONBF, _IOFBF, _IOLBF}`.
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum BufMode {
104    No,
105    Full,
106    Line,
107}
108
109/// Which standard stream to wrap in `create_std_file`.
110pub enum StdFileKind {
111    Stdin,
112    Stdout,
113    Stderr,
114}
115
116// ── Structs ──────────────────────────────────────────────────────────────────
117
118/// Lua file handle stored as the typed payload of a `LuaUserData`.
119///
120/// C equivalent: `typedef luaL_Stream LStream` in `liolib.c`.
121///
122/// TODO(port): Phase B must arrange for `LStream` to live inside
123/// `LuaUserData`'s opaque payload. The userdata system needs a typed-access
124/// API, e.g. `state.check_arg_typed_userdata::<LStream>(1, LUA_FILE_HANDLE)?`.
125///
126/// TODO(port): `file` must be `Option<RefCell<Box<dyn LuaFileOps>>>` to allow
127/// interior-mutability borrow splitting between the file handle and `LuaState`.
128pub struct LStream {
129    /// OS file handle. `None` = incompletely opened (pre-file pattern).
130    /// Concrete implementations are installed via `GlobalState::file_open_hook`
131    /// (registered by `lua-cli`) to keep `std::fs` out of `lua-stdlib`.
132    pub file: Option<Box<dyn LuaFileHandle>>,
133    /// Close callback. `None` means the stream is closed. C: `p->closef == NULL`.
134    pub close_fn: Option<fn(&mut LuaState) -> Result<usize, LuaError>>,
135}
136
137impl LStream {
138    /// `isclosed(p)` in C: true when `closef` is NULL.
139    pub fn is_closed(&self) -> bool {
140        self.close_fn.is_none()
141    }
142}
143
144/// Standard stream handle for stdin/stdout/stderr.
145///
146/// Output goes through host hooks when installed. Native builds keep a direct
147/// stdio fallback for compatibility; bare `wasm32-unknown-unknown` reports
148/// unsupported instead of touching stubbed stdio.
149struct StdStreamHandle {
150    kind: StdFileKind,
151    input_hook: Option<InputHook>,
152    output_hook: Option<OutputHook>,
153    unread: Option<u8>,
154}
155
156impl LuaFileHandle for StdStreamHandle {
157    fn read_byte(&mut self) -> i32 {
158        if let Some(byte) = self.unread.take() {
159            return byte as i32;
160        }
161        match self.kind {
162            StdFileKind::Stdin => {
163                if let Some(read_fn) = self.input_hook {
164                    let mut buf = [0u8; 1];
165                    return match read_fn(&mut buf) {
166                        Ok(1) => buf[0] as i32,
167                        _ => EOF_SENTINEL,
168                    };
169                }
170
171                #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
172                {
173                    EOF_SENTINEL
174                }
175
176                #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
177                {
178                    use std::io::Read;
179                    let mut buf = [0u8; 1];
180                    match std::io::stdin().read(&mut buf) {
181                        Ok(1) => buf[0] as i32,
182                        _ => EOF_SENTINEL,
183                    }
184                }
185            }
186            _ => EOF_SENTINEL,
187        }
188    }
189    fn unread_byte(&mut self, byte: i32) {
190        if (0..=u8::MAX as i32).contains(&byte) {
191            self.unread = Some(byte as u8);
192        }
193    }
194    fn write_bytes(&mut self, data: &[u8]) -> io::Result<usize> {
195        if let Some(write_fn) = self.output_hook {
196            write_fn(data)?;
197            return Ok(data.len());
198        }
199
200        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
201        {
202            let _ = data;
203            return Err(io::Error::new(
204                io::ErrorKind::Unsupported,
205                "standard output not available in this host",
206            ));
207        }
208
209        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
210        {
211            use std::io::Write;
212            match self.kind {
213                StdFileKind::Stderr => {
214                    std::io::stderr().write_all(data)?;
215                    Ok(data.len())
216                }
217                _ => {
218                    std::io::stdout().write_all(data)?;
219                    Ok(data.len())
220                }
221            }
222        }
223    }
224    fn flush(&mut self) -> io::Result<()> {
225        if self.output_hook.is_some() {
226            return Ok(());
227        }
228
229        #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
230        {
231            return Err(io::Error::new(
232                io::ErrorKind::Unsupported,
233                "standard output not available in this host",
234            ));
235        }
236
237        #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
238        {
239            use std::io::Write;
240            match self.kind {
241                StdFileKind::Stderr => std::io::stderr().flush(),
242                _ => std::io::stdout().flush(),
243            }
244        }
245    }
246    fn seek(&mut self, _pos: SeekFrom) -> io::Result<u64> {
247        Err(io::Error::new(io::ErrorKind::Unsupported, "stdio seek"))
248    }
249    fn tell(&mut self) -> io::Result<u64> {
250        Err(io::Error::new(io::ErrorKind::Unsupported, "stdio tell"))
251    }
252    fn clear_error(&mut self) {}
253    fn has_error(&self) -> bool {
254        false
255    }
256}
257
258impl LuaFileOps for StdStreamHandle {
259    fn set_buf_mode(&mut self, _mode: BufMode, _size: usize) -> io::Result<()> {
260        Ok(())
261    }
262}
263
264impl StdStreamHandle {
265    fn new(
266        kind: StdFileKind,
267        input_hook: Option<InputHook>,
268        output_hook: Option<OutputHook>,
269    ) -> Self {
270        StdStreamHandle {
271            kind,
272            input_hook,
273            output_hook,
274            unread: None,
275        }
276    }
277}
278
279/// State machine for reading a numeric literal byte-by-byte from a file.
280struct ReadNumState {
281    /// Current look-ahead byte, or `EOF_SENTINEL`.
282    current: i32,
283    /// Number of bytes accumulated in `buf`.
284    count: usize,
285    /// Accumulated characters of the numeral (NUL-terminated on finalise).
286    buf: [u8; L_MAX_LEN_NUM + 1],
287}
288
289impl ReadNumState {
290    fn new(first_byte: i32) -> Self {
291        ReadNumState {
292            current: first_byte,
293            count: 0,
294            buf: [0u8; L_MAX_LEN_NUM + 1],
295        }
296    }
297
298    /// Save current char to `buf` and read the next byte from `file`.
299    /// Returns `false` if the buffer is full (numeral too long). C: `nextc`.
300    fn advance(&mut self, file: &mut dyn LuaFileHandle) -> bool {
301        if self.count >= L_MAX_LEN_NUM {
302            self.buf[0] = 0;
303            return false;
304        }
305        self.buf[self.count] = self.current as u8;
306        self.count += 1;
307        self.current = file.read_byte();
308        true
309    }
310
311    /// Accept current char if it equals either byte in `set`. C: `test2`.
312    fn try2(&mut self, file: &mut dyn LuaFileHandle, set: [u8; 2]) -> bool {
313        if self.current == set[0] as i32 || self.current == set[1] as i32 {
314            self.advance(file)
315        } else {
316            false
317        }
318    }
319
320    /// Consume a run of (hex)digits; return the count. C: `readdigits`.
321    fn read_digits(&mut self, file: &mut dyn LuaFileHandle, hex: bool) -> usize {
322        let mut count = 0usize;
323        loop {
324            let is_digit = if hex {
325                (self.current as u8).is_ascii_hexdigit()
326            } else {
327                (self.current as u8).is_ascii_digit()
328            };
329            if !is_digit || self.current == EOF_SENTINEL {
330                break;
331            }
332            if !self.advance(file) {
333                break;
334            }
335            count += 1;
336        }
337        count
338    }
339
340    /// Return the accumulated bytes (without the NUL terminator).
341    fn as_bytes(&self) -> &[u8] {
342        &self.buf[..self.count]
343    }
344}
345
346// ── Function registration tables ─────────────────────────────────────────────
347
348/// `io.*` module functions. C: `static const luaL_Reg iolib[]`.
349pub const IO_LIB: &[(&[u8], fn(&mut LuaState) -> Result<usize, LuaError>)] = &[
350    (b"close", io_close),
351    (b"flush", io_flush),
352    (b"input", io_input),
353    (b"lines", io_lines),
354    (b"open", io_open),
355    (b"output", io_output),
356    (b"popen", io_popen),
357    (b"read", io_read),
358    (b"tmpfile", io_tmpfile),
359    (b"type", io_type),
360    (b"write", io_write),
361];
362
363/// `file:*` instance methods. C: `static const luaL_Reg meth[]`.
364pub const FILE_METHODS: &[(&[u8], fn(&mut LuaState) -> Result<usize, LuaError>)] = &[
365    (b"read", f_read),
366    (b"write", f_write),
367    (b"lines", f_lines),
368    (b"flush", f_flush),
369    (b"seek", f_seek),
370    (b"close", f_close),
371    (b"setvbuf", f_setvbuf),
372];
373
374/// File-handle metamethods. C: `static const luaL_Reg metameth[]`.
375pub const FILE_METAMETHODS: &[(&[u8], fn(&mut LuaState) -> Result<usize, LuaError>)] = &[
376    (b"__gc", f_gc),
377    (b"__close", f_gc),
378    (b"__tostring", f_tostring),
379];
380
381// ── Helpers ──────────────────────────────────────────────────────────────────
382
383/// Validate an `fopen` mode string: must match `[rwa]\+?b*`. C: `l_checkmode`.
384///
385/// (*mode != '+' || ...) && strspn(mode, "b") == strlen(mode));`
386fn check_mode(mode: &[u8]) -> bool {
387    if mode.is_empty() {
388        return false;
389    }
390    let mut idx = 0usize;
391    if !matches!(mode[idx], b'r' | b'w' | b'a') {
392        return false;
393    }
394    idx += 1;
395    if idx < mode.len() && mode[idx] == b'+' {
396        idx += 1;
397    }
398    mode[idx..].iter().all(|&b| b == b'b')
399}
400
401/// Validate a `popen` mode string: only `"r"` or `"w"`. C: `l_checkmodep`.
402fn check_mode_popen(mode: &[u8]) -> bool {
403    matches!(mode, b"r" | b"w")
404}
405
406/// Push success (`true`) or failure (`false`, msg, errno) per `luaL_fileresult`.
407///
408/// else { luaL_pushfail; pushstring(msg); pushinteger(errno); return 3; }`
409fn file_result(
410    state: &mut LuaState,
411    success: bool,
412    fname: Option<&[u8]>,
413    os_err: io::Error,
414) -> Result<usize, LuaError> {
415    if success {
416        state.push(LuaValue::Bool(true));
417        return Ok(1);
418    }
419    state.push(LuaValue::Bool(false));
420    let msg = os_err.to_string();
421    match fname {
422        Some(name) => {
423            let mut s = Vec::with_capacity(name.len() + 2 + msg.len());
424            s.extend_from_slice(name);
425            s.extend_from_slice(b": ");
426            s.extend_from_slice(msg.as_bytes());
427            state.push_string(&s)?;
428        }
429        None => {
430            state.push_string(msg.as_bytes())?;
431        }
432    }
433    let errno_code = os_err.raw_os_error().unwrap_or(0) as i64;
434    state.push(LuaValue::Int(errno_code));
435    Ok(3)
436}
437
438/// Push popen/system exit-status results per `luaL_execresult`.
439///
440/// else { luaL_pushfail; pushlstring("exit"|"signal"); pushinteger(stat); return 3; }`
441///
442/// TODO(port): POSIX `WIFEXITED`/`WTERMSIG` macros not available on all platforms;
443/// this stub always treats non-zero stat as an exit code.
444fn exec_result(state: &mut LuaState, stat: i32) -> Result<usize, LuaError> {
445    if stat == 0 {
446        state.push(LuaValue::Bool(true));
447        Ok(1)
448    } else {
449        state.push(LuaValue::Bool(false));
450        // TODO(port): distinguish exit vs signal via POSIX macros
451        state.push_string(b"exit")?;
452        state.push(LuaValue::Int(stat as i64));
453        Ok(3)
454    }
455}
456
457/// Retrieve `LStream` from argument 1 via a userdata type-check.
458///
459/// Returns an `Rc<RefCell<LStream>>` from the side-table registry. The C port
460/// returns a raw `LStream *` pointing into the userdata payload; Rust uses a
461/// side table because `LStream` contains heap pointers that cannot be safely
462/// reinterpreted from a raw byte buffer in safe Rust.
463fn get_lstream(state: &mut LuaState) -> Result<Rc<RefCell<LStream>>, LuaError> {
464    let ud = state.check_arg_userdata(1, LUA_FILE_HANDLE)?;
465    lookup_lstream(ud.identity())
466        .ok_or_else(|| LuaError::runtime(format_args!("invalid file handle")))
467}
468
469/// Look up the `LStream` registered for the userdata sitting at upvalue `idx`.
470///
471/// `aux_lines` stores the file-handle userdata as upvalue 1 of `io_readline`;
472/// this helper performs the same registry round-trip that `get_lstream` does
473/// for argument 1, but reads the value from the closure's upvalue slot instead
474/// of the call stack.
475fn lstream_from_upvalue(state: &mut LuaState, idx: i32) -> Result<Rc<RefCell<LStream>>, LuaError> {
476    let v = state.value_at(crate::state_stub::upvalue_index(idx));
477    let ud_id = match v {
478        LuaValue::UserData(ud) => ud.identity(),
479        _ => {
480            return Err(LuaError::runtime(format_args!(
481                "invalid file handle in upvalue {}",
482                idx
483            )));
484        }
485    };
486    lookup_lstream(ud_id)
487        .ok_or_else(|| LuaError::runtime(format_args!("invalid file handle in upvalue {}", idx)))
488}
489
490/// Validate that argument 1 is an open file handle; error if closed.
491fn tofile(state: &mut LuaState) -> Result<Rc<RefCell<LStream>>, LuaError> {
492    let p_rc = get_lstream(state)?;
493    {
494        let p = p_rc.borrow();
495        if p.is_closed() {
496            return Err(LuaError::runtime(format_args!(
497                "attempt to use a closed file"
498            )));
499        }
500        debug_assert!(p.file.is_some());
501    }
502    Ok(p_rc)
503}
504
505// ── File creation helpers ────────────────────────────────────────────────────
506
507/// Allocate a "closed" file-handle userdata and push it; set its metatable.
508/// Also registers an empty `LStream` in the side table keyed by the userdata
509/// identity, and returns the `Rc<RefCell<LStream>>` so the caller may finish
510/// initialising it (set `file`, set `close_fn`). C: `newprefile(L)`.
511fn new_pre_file(state: &mut LuaState) -> Result<Rc<RefCell<LStream>>, LuaError> {
512    let ud = state.new_userdata_typed(LUA_FILE_HANDLE, std::mem::size_of::<LStream>(), 0)?;
513    state.set_metatable_by_name(LUA_FILE_HANDLE)?;
514    let cell = register_lstream(
515        ud.identity(),
516        LStream {
517            file: None,
518            close_fn: None,
519        },
520    );
521    Ok(cell)
522}
523
524/// Allocate a new regular-file handle with `io_fclose` as the close function.
525fn new_file(state: &mut LuaState) -> Result<Rc<RefCell<LStream>>, LuaError> {
526    let cell = new_pre_file(state)?;
527    cell.borrow_mut().close_fn = Some(io_fclose);
528    Ok(cell)
529}
530
531/// Open `fname` and push its handle; raise a runtime error on failure.
532///
533/// The file system is reached via `GlobalState::file_open_hook` (registered by
534/// `lua-cli`) since `std::fs` is banned in `lua-stdlib` per PORTING.md §1.
535fn opencheck(state: &mut LuaState, fname: &[u8], mode: &[u8]) -> Result<(), LuaError> {
536    let hook = state.global().file_open_hook;
537    let fh = match hook {
538        Some(open_fn) => open_fn(fname, mode).map_err(|e| {
539            LuaError::runtime(format_args!(
540                "cannot open file '{}' ({})",
541                fname.escape_ascii(),
542                match &e {
543                    LuaError::Runtime(LuaValue::Str(s)) => {
544                        String::from_utf8_lossy(s.as_bytes()).into_owned()
545                    }
546                    other => format!("{:?}", other),
547                }
548            ))
549        })?,
550        None => {
551            return Err(LuaError::runtime(format_args!(
552                "cannot open file '{}' (no filesystem hook registered)",
553                fname.escape_ascii()
554            )));
555        }
556    };
557    let cell = new_file(state)?;
558    cell.borrow_mut().file = Some(fh);
559    Ok(())
560}
561
562// ── Close functions ──────────────────────────────────────────────────────────
563
564/// Close a regular file via `fclose`. C: `io_fclose`.
565///
566/// TODO(port): flush + drop `Box<dyn LuaFileOps>`, map io::Error to file_result.
567fn io_fclose(state: &mut LuaState) -> Result<usize, LuaError> {
568    let p_rc = get_lstream(state)?;
569    // TODO(port): actually flush then drop p.file, capture any error
570    let _closed = p_rc.borrow_mut().file.take();
571    state.push(LuaValue::Bool(true));
572    Ok(1)
573}
574
575/// Close a popen process pipe. C: `io_pclose`.
576///
577/// TODO(port): std::process::Child — popen not yet implemented.
578fn io_pclose(state: &mut LuaState) -> Result<usize, LuaError> {
579    let p_rc = get_lstream(state)?;
580    let _closed = p_rc.borrow_mut().file.take();
581    // TODO(port): wait on the child process and forward its exit code
582    exec_result(state, 0)
583}
584
585/// Refuse to close a standard-stream handle. C: `io_noclose`.
586fn io_noclose(state: &mut LuaState) -> Result<usize, LuaError> {
587    let p_rc = get_lstream(state)?;
588    p_rc.borrow_mut().close_fn = Some(io_noclose); // reinstall to keep the handle alive
589    state.push(LuaValue::Bool(false));
590    state.push_string(b"cannot close standard file")?;
591    Ok(2)
592}
593
594/// Invoke the stream's close function and mark it closed. C: `aux_close`.
595fn aux_close(state: &mut LuaState) -> Result<usize, LuaError> {
596    let p_rc = get_lstream(state)?;
597    let cf = p_rc.borrow_mut().close_fn.take().ok_or_else(|| {
598        LuaError::runtime(format_args!("attempt to close an already-closed file"))
599    })?;
600    cf(state)
601}
602
603// ── io.type ──────────────────────────────────────────────────────────────────
604
605/// `io.type(x)` — return `"file"`, `"closed file"`, or `false`. C: `io_type`.
606pub fn io_type(state: &mut LuaState) -> Result<usize, LuaError> {
607    state.check_arg_any(1)?;
608    let maybe_userdata = state.test_arg_userdata(1, LUA_FILE_HANDLE);
609    match maybe_userdata {
610        None => {
611            state.push(LuaValue::Bool(false));
612        }
613        Some(ud) => {
614            let is_closed = match lookup_lstream(ud.identity()) {
615                Some(rc) => rc.borrow().is_closed(),
616                None => true, // unknown userdata with FILE* metatable: treat as closed
617            };
618            if is_closed {
619                state.push_string(b"closed file")?;
620            } else {
621                state.push_string(b"file")?;
622            }
623        }
624    }
625    Ok(1)
626}
627
628// ── __tostring metamethod ────────────────────────────────────────────────────
629
630/// `tostring(file)` metamethod. C: `f_tostring`.
631fn f_tostring(state: &mut LuaState) -> Result<usize, LuaError> {
632    let p_rc = get_lstream(state)?;
633    let closed = p_rc.borrow().is_closed();
634    if closed {
635        state.push_string(b"file (closed)")?;
636    } else {
637        // TODO(port): pointer-address representation for the file handle
638        state.push_string(b"file (0x?)")?;
639    }
640    Ok(1)
641}
642
643// ── close / gc ───────────────────────────────────────────────────────────────
644
645/// `file:close()`. C: `f_close`.
646fn f_close(state: &mut LuaState) -> Result<usize, LuaError> {
647    let _ = tofile(state)?; // validates stream is open before closing
648    aux_close(state)
649}
650
651/// `io.close([file])`. C: `io_close`.
652pub fn io_close(state: &mut LuaState) -> Result<usize, LuaError> {
653    // The pushed value naturally lands at position 1 (top advances by one from
654    // func+1 to func+2). The C source does NOT call lua_replace here; adding one
655    // would pop the value back out, since position 1 equals top-1 in this case.
656    if state.type_at(1) == LuaType::None {
657        state.registry_get(IO_OUTPUT_KEY)?;
658    }
659    f_close(state)
660}
661
662/// `__gc` / `__close` metamethod — silently close if still open. C: `f_gc`.
663fn f_gc(state: &mut LuaState) -> Result<usize, LuaError> {
664    let p_rc = get_lstream(state)?;
665    let needs_close = {
666        let p = p_rc.borrow();
667        !p.is_closed() && p.file.is_some()
668    };
669    if needs_close {
670        // ignore any error from aux_close during GC finalisation
671        let _ = aux_close(state);
672    }
673    Ok(0)
674}
675
676// ── io.open / io.popen / io.tmpfile ─────────────────────────────────────────
677
678/// `io.open(filename [, mode])`. C: `io_open`.
679///
680/// The file system is reached via `GlobalState::file_open_hook` (registered by
681/// `lua-cli`) since `std::fs` is banned in `lua-stdlib` per PORTING.md §1.
682pub fn io_open(state: &mut LuaState) -> Result<usize, LuaError> {
683    let filename: Vec<u8> = state.check_arg_string(1)?;
684    let mode: Vec<u8> = state.opt_arg_string(2, b"r")?;
685    if !check_mode(&mode) {
686        return Err(lua_vm::debug::arg_error_impl(state, 2, b"invalid mode"));
687    }
688    let hook = state.global().file_open_hook;
689    match hook {
690        Some(open_fn) => match open_fn(&filename, &mode) {
691            Ok(fh) => {
692                let cell = new_file(state)?;
693                cell.borrow_mut().file = Some(fh);
694                Ok(1)
695            }
696            Err(e) => {
697                let os_err = io::Error::new(
698                    io::ErrorKind::Other,
699                    match &e {
700                        LuaError::Runtime(LuaValue::Str(s)) => {
701                            String::from_utf8_lossy(s.as_bytes()).into_owned()
702                        }
703                        other => format!("{:?}", other),
704                    },
705                );
706                file_result(state, false, Some(&filename), os_err)
707            }
708        },
709        None => {
710            let os_err =
711                io::Error::new(io::ErrorKind::Unsupported, "no filesystem hook registered");
712            file_result(state, false, Some(&filename), os_err)
713        }
714    }
715}
716
717/// `io.popen(filename [, mode])`. C: `io_popen`.
718///
719/// `std::process::Command` is banned in `lua-stdlib`; the child process is
720/// spawned via `GlobalState::popen_hook`, which `lua-cli` installs. When the
721/// hook is absent (sandboxed embeddings), this returns a clean Lua failure
722/// shape (`nil, errmsg, errno`) rather than panicking, so clients such as
723/// LuaRocks that probe `io.popen` fall back gracefully instead of crashing
724/// the host.
725pub fn io_popen(state: &mut LuaState) -> Result<usize, LuaError> {
726    let filename: Vec<u8> = state.check_arg_string(1)?;
727    let mode: Vec<u8> = state.opt_arg_string(2, b"r")?;
728    if !check_mode_popen(&mode) {
729        return Err(lua_vm::debug::arg_error_impl(state, 2, b"invalid mode"));
730    }
731    let hook = state.global().popen_hook;
732    match hook {
733        Some(spawn_fn) => match spawn_fn(&filename, &mode) {
734            Ok(fh) => {
735                let cell = new_pre_file(state)?;
736                let mut p = cell.borrow_mut();
737                p.file = Some(fh);
738                p.close_fn = Some(io_pclose);
739                drop(p);
740                Ok(1)
741            }
742            Err(e) => {
743                let os_err = io::Error::new(
744                    io::ErrorKind::Other,
745                    match &e {
746                        LuaError::Runtime(LuaValue::Str(s)) => {
747                            String::from_utf8_lossy(s.as_bytes()).into_owned()
748                        }
749                        other => format!("{:?}", other),
750                    },
751                );
752                file_result(state, false, Some(&filename), os_err)
753            }
754        },
755        None => {
756            let os_err = io::Error::new(
757                io::ErrorKind::Unsupported,
758                "popen not enabled in this build",
759            );
760            file_result(state, false, Some(&filename), os_err)
761        }
762    }
763}
764
765fn native_temp_name() -> io::Result<Vec<u8>> {
766    #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
767    {
768        return Err(io::Error::new(
769            io::ErrorKind::Unsupported,
770            "temporary files not available in this host",
771        ));
772    }
773
774    #[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
775    {
776        let mut path = std::env::temp_dir().to_string_lossy().as_bytes().to_vec();
777        if path.last().copied() != Some(b'/') && path.last().copied() != Some(b'\\') {
778            path.push(b'/');
779        }
780        let unique = format!(
781            "lua_tmpfile_{}_{}",
782            std::process::id(),
783            std::time::SystemTime::now()
784                .duration_since(std::time::UNIX_EPOCH)
785                .map(|d| d.as_nanos())
786                .unwrap_or(0)
787        );
788        path.extend_from_slice(unique.as_bytes());
789        Ok(path)
790    }
791}
792
793/// `io.tmpfile()`. C: `io_tmpfile`.
794pub fn io_tmpfile(state: &mut LuaState) -> Result<usize, LuaError> {
795    let hook = state.global().file_open_hook;
796    let Some(open_fn) = hook else {
797        let os_err = io::Error::new(io::ErrorKind::Unsupported, "no filesystem hook registered");
798        return file_result(state, false, None, os_err);
799    };
800
801    let temp_name_hook = state.global().temp_name_hook;
802    let path = match temp_name_hook {
803        Some(temp_fn) => match temp_fn() {
804            Ok(path) => path,
805            Err(e) => {
806                let msg = match &e {
807                    LuaError::Runtime(LuaValue::Str(s)) => {
808                        String::from_utf8_lossy(s.as_bytes()).into_owned()
809                    }
810                    other => format!("{:?}", other),
811                };
812                return file_result(
813                    state,
814                    false,
815                    None,
816                    io::Error::new(io::ErrorKind::Unsupported, msg),
817                );
818            }
819        },
820        None => match native_temp_name() {
821            Ok(path) => path,
822            Err(e) => return file_result(state, false, None, e),
823        },
824    };
825
826    match open_fn(&path, b"w+b") {
827        Ok(fh) => {
828            let cell = new_file(state)?;
829            cell.borrow_mut().file = Some(fh);
830            Ok(1)
831        }
832        Err(e) => {
833            let os_err = io::Error::new(
834                io::ErrorKind::Other,
835                match &e {
836                    LuaError::Runtime(LuaValue::Str(s)) => {
837                        String::from_utf8_lossy(s.as_bytes()).into_owned()
838                    }
839                    other => format!("{:?}", other),
840                },
841            );
842            file_result(state, false, None, os_err)
843        }
844    }
845}
846
847// ── io.input / io.output ─────────────────────────────────────────────────────
848
849/// Retrieve the current default IO file from the registry; error if closed.
850///
851/// TODO(port): borrow split — returns `&mut dyn LuaFileHandle` while caller also
852/// needs `&mut LuaState`. Phase B: use `RefCell` inside `LStream`.
853#[expect(
854    dead_code,
855    unreachable_code,
856    unused_variables,
857    reason = "io default-file helper: not yet wired; pending LStream-from-registry port"
858)]
859fn get_io_file<'a>(
860    state: &'a mut LuaState,
861    key: &[u8],
862) -> Result<&'a mut dyn LuaFileHandle, LuaError> {
863    state.registry_get(key)?;
864    // TODO(port): extract &mut LStream from the registry value's userdata payload
865    let label = &key[IO_PREFIX_LEN..]; // strip "_IO_" for the error message
866    let p: &mut LStream = todo!("TODO(port): extract LStream from registry userdata");
867    if p.is_closed() {
868        return Err(LuaError::runtime(format_args!(
869            "default {} file is closed",
870            label.escape_ascii()
871        )));
872    }
873    Ok(p.file
874        .as_mut()
875        .expect("open stream has no file handle")
876        .as_mut())
877}
878
879/// Generic setter/getter for `io.input` and `io.output`. C: `g_iofile`.
880fn g_iofile(state: &mut LuaState, key: &[u8], mode: &[u8]) -> Result<usize, LuaError> {
881    if !matches!(state.type_at(1), LuaType::None | LuaType::Nil) {
882        if state.type_at(1) == LuaType::String {
883            let filename = state.check_arg_string(1)?;
884            opencheck(state, &filename, mode)?;
885        } else {
886            let _ = tofile(state)?;
887            state.push_value_at(1)?;
888        }
889        state.registry_set(key)?;
890    }
891    state.registry_get(key)?;
892    Ok(1)
893}
894
895/// `io.input([file])`. C: `io_input`.
896pub fn io_input(state: &mut LuaState) -> Result<usize, LuaError> {
897    g_iofile(state, IO_INPUT_KEY, b"r")
898}
899
900/// `io.output([file])`. C: `io_output`.
901pub fn io_output(state: &mut LuaState) -> Result<usize, LuaError> {
902    g_iofile(state, IO_OUTPUT_KEY, b"w")
903}
904
905// ── Read helpers ─────────────────────────────────────────────────────────────
906
907/// Read a numeric literal from `file` into an owned byte buffer.
908fn read_number_bytes(file: &mut dyn LuaFileHandle) -> Vec<u8> {
909    let first = loop {
910        let b = file.read_byte();
911        if b == EOF_SENTINEL || !(b as u8).is_ascii_whitespace() {
912            break b;
913        }
914    };
915
916    let mut rn = ReadNumState::new(first);
917
918    rn.try2(file, [b'-', b'+']);
919
920    let mut count: usize = 0;
921    let hex = if rn.try2(file, [b'0', b'0']) {
922        if rn.try2(file, [b'x', b'X']) {
923            true
924        } else {
925            count = 1;
926            false
927        }
928    } else {
929        false
930    };
931
932    count += rn.read_digits(file, hex);
933
934    // TODO(port): locale decimal-point character; defaulting to '.'
935    let dec_point = b'.';
936    if rn.try2(file, [dec_point, b'.']) {
937        count += rn.read_digits(file, hex);
938    }
939
940    if count > 0 {
941        let exp_chars = if hex { [b'p', b'P'] } else { [b'e', b'E'] };
942        if rn.try2(file, exp_chars) {
943            rn.try2(file, [b'-', b'+']);
944            rn.read_digits(file, false);
945        }
946    }
947
948    file.unread_byte(rn.current);
949    rn.as_bytes().to_vec()
950}
951
952/// Peek for EOF: returns `true` if more input is available. C: `test_eof`
953/// (the file-only half — caller still pushes `""` regardless).
954fn test_eof(file: &mut dyn LuaFileHandle) -> bool {
955    let c = file.read_byte();
956    if c != EOF_SENTINEL {
957        file.unread_byte(c);
958    }
959    c != EOF_SENTINEL
960}
961
962/// Read one line from `file` into an owned buffer. Returns `(bytes, had_content)`.
963/// If `chop` is true the trailing `\n` is stripped. C: `read_line(L, f, chop)`.
964///
965/// PERF(port): C uses luaL_prepbuffer (large fixed stack buffer) to avoid
966/// per-byte allocation; Rust's Vec grows here, which is slightly slower.
967fn read_line(file: &mut dyn LuaFileHandle, chop: bool) -> (Vec<u8>, bool) {
968    let mut buf: Vec<u8> = Vec::new();
969    let mut c: i32;
970
971    //          while (i < LUAL_BUFFERSIZE && (c = l_getc(f)) != EOF && c != '\n')
972    //            buff[i++] = c;
973    //          luaL_addsize(&b, i);
974    //    } while (c != EOF && c != '\n');
975    'outer: loop {
976        for _ in 0..LUAL_BUFFER_SIZE {
977            c = file.read_byte();
978            if c == EOF_SENTINEL || c == b'\n' as i32 {
979                break 'outer;
980            }
981            buf.push(c as u8);
982        }
983        // chunk full but no newline/EOF yet — continue reading
984    }
985
986    if !chop && c == b'\n' as i32 {
987        buf.push(b'\n');
988    }
989
990    let had_content = c == b'\n' as i32 || !buf.is_empty();
991    (buf, had_content)
992}
993
994/// Read the entire file into an owned buffer. C: `read_all(L, f)` (file-only half).
995///
996/// PERF(port): C uses `fread` with a large buffer; Rust reads byte-by-byte via
997/// `LuaFileOps::read_byte`. Phase B should add `read_chunk(&mut buf)` to the
998/// trait for bulk reads.
999fn read_all(file: &mut dyn LuaFileHandle) -> Vec<u8> {
1000    let mut buf: Vec<u8> = Vec::new();
1001    loop {
1002        let mut chunk_read = 0usize;
1003        for _ in 0..LUAL_BUFFER_SIZE {
1004            let b = file.read_byte();
1005            if b == EOF_SENTINEL {
1006                break;
1007            }
1008            buf.push(b as u8);
1009            chunk_read += 1;
1010        }
1011        if chunk_read < LUAL_BUFFER_SIZE {
1012            break;
1013        }
1014    }
1015    buf
1016}
1017
1018/// Read at most `n` bytes from `file`. Returns `(bytes, had_content)`.
1019fn read_chars(file: &mut dyn LuaFileHandle, n: usize) -> (Vec<u8>, bool) {
1020    let mut buf = Vec::with_capacity(n);
1021    for _ in 0..n {
1022        let b = file.read_byte();
1023        if b == EOF_SENTINEL {
1024            break;
1025        }
1026        buf.push(b as u8);
1027    }
1028    let nr = buf.len();
1029    (buf, nr > 0)
1030}
1031
1032/// Dispatch one or more read formats; push results. C: `g_read(L, f, first)`.
1033///
1034/// Takes an `Rc<RefCell<LStream>>` so each I/O step can borrow the file briefly,
1035/// release the borrow, then push the result to `state`. This is the "collect
1036/// then borrow" pattern that resolves the `&mut state` vs `&mut file` conflict.
1037fn g_read(
1038    state: &mut LuaState,
1039    p_rc: &Rc<RefCell<LStream>>,
1040    first: i32,
1041) -> Result<usize, LuaError> {
1042    //
1043    // In C, `getiofile` leaves the default stream on the stack, so subtracting
1044    // one skips that extra value. This Rust port resolves registry streams into
1045    // an Rc and pops the registry value before reaching `g_read`, so count the
1046    // read formats directly from `first`.
1047    let nargs = (state.top() - first + 1).max(0);
1048    let mut n = first;
1049    let mut success = true;
1050
1051    {
1052        let mut p = p_rc.borrow_mut();
1053        let fh = p.file.as_mut().expect("open stream has no file handle");
1054        fh.clear_error();
1055    }
1056
1057    if nargs == 0 {
1058        let (bytes, had) = {
1059            let mut p = p_rc.borrow_mut();
1060            let fh = p
1061                .file
1062                .as_deref_mut()
1063                .expect("open stream has no file handle");
1064            read_line(fh, true)
1065        };
1066        state.push_string(&bytes)?;
1067        success = had;
1068        n = first + 1;
1069    } else {
1070        state.ensure_stack((nargs as i32) + 20, "too many arguments")?;
1071        let mut remaining = nargs;
1072        while remaining > 0 && success {
1073            if state.type_at(n) == LuaType::Number {
1074                let l = state.check_arg_integer(n)? as usize;
1075                if l == 0 {
1076                    let not_eof = {
1077                        let mut p = p_rc.borrow_mut();
1078                        let fh = p
1079                            .file
1080                            .as_deref_mut()
1081                            .expect("open stream has no file handle");
1082                        test_eof(fh)
1083                    };
1084                    state.push_string(b"")?;
1085                    success = not_eof;
1086                } else {
1087                    let (bytes, had) = {
1088                        let mut p = p_rc.borrow_mut();
1089                        let fh = p
1090                            .file
1091                            .as_deref_mut()
1092                            .expect("open stream has no file handle");
1093                        read_chars(fh, l)
1094                    };
1095                    state.push_string(&bytes)?;
1096                    success = had;
1097                }
1098            } else {
1099                let s: Vec<u8> = state.check_arg_string(n)?;
1100                let pp: &[u8] = if s.first() == Some(&b'*') {
1101                    &s[1..]
1102                } else {
1103                    &s[..]
1104                };
1105                match pp.first() {
1106                    Some(&b'n') => {
1107                        let bytes = {
1108                            let mut p = p_rc.borrow_mut();
1109                            let fh = p
1110                                .file
1111                                .as_deref_mut()
1112                                .expect("open stream has no file handle");
1113                            read_number_bytes(fh)
1114                        };
1115                        let pushed = state.string_to_number_push(&bytes)?;
1116                        if pushed != 0 {
1117                            success = true;
1118                        } else {
1119                            state.push(LuaValue::Nil);
1120                            success = false;
1121                        }
1122                    }
1123                    Some(&b'l') => {
1124                        let (bytes, had) = {
1125                            let mut p = p_rc.borrow_mut();
1126                            let fh = p
1127                                .file
1128                                .as_deref_mut()
1129                                .expect("open stream has no file handle");
1130                            read_line(fh, true)
1131                        };
1132                        state.push_string(&bytes)?;
1133                        success = had;
1134                    }
1135                    Some(&b'L') => {
1136                        let (bytes, had) = {
1137                            let mut p = p_rc.borrow_mut();
1138                            let fh = p
1139                                .file
1140                                .as_deref_mut()
1141                                .expect("open stream has no file handle");
1142                            read_line(fh, false)
1143                        };
1144                        state.push_string(&bytes)?;
1145                        success = had;
1146                    }
1147                    Some(&b'a') => {
1148                        let bytes = {
1149                            let mut p = p_rc.borrow_mut();
1150                            let fh = p
1151                                .file
1152                                .as_deref_mut()
1153                                .expect("open stream has no file handle");
1154                            read_all(fh)
1155                        };
1156                        state.push_string(&bytes)?;
1157                        success = true;
1158                    }
1159                    _ => {
1160                        return Err(lua_vm::debug::arg_error_impl(state, n, b"invalid format"));
1161                    }
1162                }
1163            }
1164            n += 1;
1165            remaining -= 1;
1166        }
1167    }
1168
1169    let has_err = {
1170        let p = p_rc.borrow();
1171        match p.file.as_deref() {
1172            Some(fh) => fh.has_error(),
1173            None => false,
1174        }
1175    };
1176    if has_err {
1177        let err = {
1178            let p = p_rc.borrow();
1179            match p.file.as_deref().and_then(|fh| fh.last_error_info()) {
1180                Some((code, _msg)) if code != 0 => io::Error::from_raw_os_error(code),
1181                Some((_code, msg)) => io::Error::new(io::ErrorKind::Other, msg),
1182                None => io::Error::new(io::ErrorKind::Other, "file read error"),
1183            }
1184        };
1185        return file_result(state, false, None, err);
1186    }
1187
1188    if !success {
1189        state.pop_n(1);
1190        state.push(LuaValue::Nil);
1191    }
1192
1193    Ok((n - first) as usize)
1194}
1195
1196/// Resolve the registry-default I/O file (IO_INPUT / IO_OUTPUT) into its
1197/// backing `Rc<RefCell<LStream>>`. Errors if the slot holds a closed handle
1198/// or a value that is not a registered file userdata.
1199///
1200fn get_io_file_rc(state: &mut LuaState, key: &[u8]) -> Result<Rc<RefCell<LStream>>, LuaError> {
1201    state.registry_get(key)?;
1202    let ud_id = state
1203        .test_arg_userdata(-1, LUA_FILE_HANDLE)
1204        .map(|ud| ud.identity());
1205    state.pop_n(1);
1206    let label = &key[IO_PREFIX_LEN..];
1207    let id = ud_id.ok_or_else(|| {
1208        LuaError::runtime(format_args!(
1209            "default {} file is invalid",
1210            label.escape_ascii()
1211        ))
1212    })?;
1213    let rc = lookup_lstream(id).ok_or_else(|| {
1214        LuaError::runtime(format_args!(
1215            "default {} file is invalid",
1216            label.escape_ascii()
1217        ))
1218    })?;
1219    if rc.borrow().is_closed() {
1220        return Err(LuaError::runtime(format_args!(
1221            "default {} file is closed",
1222            label.escape_ascii()
1223        )));
1224    }
1225    Ok(rc)
1226}
1227
1228/// `io.read(...)`. C: `io_read`.
1229pub fn io_read(state: &mut LuaState) -> Result<usize, LuaError> {
1230    let p_rc = get_io_file_rc(state, IO_INPUT_KEY)?;
1231    g_read(state, &p_rc, 1)
1232}
1233
1234/// `file:read(...)`. C: `f_read`.
1235pub fn f_read(state: &mut LuaState) -> Result<usize, LuaError> {
1236    let p_rc = tofile(state)?;
1237    g_read(state, &p_rc, 2)
1238}
1239
1240// ── Write helpers ────────────────────────────────────────────────────────────
1241
1242/// Render a numeric `LuaValue` to its `io.write` byte form.
1243///
1244/// C's `g_write` writes numbers with `lua_tostring`, i.e. the same
1245/// `tostringbuff` path as `print`/`tostring`. Routing through the shared,
1246/// version-aware [`lua_vm::object::num_to_string`] keeps `io.write(1.0)`
1247/// identical to `print(1.0)` on every version: `%.14g` on 5.1-5.4 (no `.0`
1248/// suffix under the float-only 5.1/5.2), shortest-round-trip on 5.5. Previously
1249/// these sites used `{:.14e}`, which always emitted scientific notation
1250/// (`0.00000000000000e0`) and only stayed hidden because the modern test
1251/// scripts wrote integers.
1252fn num_to_write_bytes(state: &mut LuaState, val: &LuaValue) -> Result<Vec<u8>, LuaError> {
1253    let s = lua_vm::object::num_to_string(state, val)?;
1254    Ok(s.as_bytes().to_vec())
1255}
1256
1257/// Dispatch one or more write values. C: `g_write(L, f, arg)`.
1258///
1259/// TODO(port): borrow split — same issue as g_read.
1260#[expect(
1261    dead_code,
1262    reason = "ported stdlib helper; not yet wired into the runtime"
1263)]
1264fn g_write(
1265    state: &mut LuaState,
1266    file: &mut dyn LuaFileHandle,
1267    arg: i32,
1268) -> Result<usize, LuaError> {
1269    let nargs = state.top() - arg;
1270    let mut overall_ok = true;
1271
1272    for i in 0..nargs {
1273        let idx = arg + i;
1274        if state.type_at(idx) == LuaType::Number {
1275            // PERF(port): byte-by-byte write; Phase B add bulk write_fmt to LuaFileOps.
1276            // C's `g_write` renders a number with `lua_tostring`, i.e. the same
1277            // `tostringbuff`/`%.14g` path as `print`/`tostring`. Route through
1278            // the shared, version-aware `num_to_string` so float rendering
1279            // matches (5.1-5.4 `%.14g`, 5.5 round-trip) instead of always-`%e`.
1280            let val = state.value_at(idx);
1281            let s = num_to_write_bytes(state, &val)?;
1282            match file.write_bytes(&s) {
1283                Ok(n) => overall_ok = overall_ok && n == s.len(),
1284                Err(_) => overall_ok = false,
1285            }
1286        } else {
1287            let s: Vec<u8> = state.check_arg_string(idx)?;
1288            match file.write_bytes(&s) {
1289                Ok(n) => overall_ok = overall_ok && n == s.len(),
1290                Err(_) => overall_ok = false,
1291            }
1292        }
1293    }
1294
1295    if overall_ok {
1296        Ok(1) // file handle already at stack top; C returns it on success
1297    } else {
1298        file_result(
1299            state,
1300            false,
1301            None,
1302            io::Error::new(io::ErrorKind::Other, "write error"),
1303        )
1304    }
1305}
1306
1307/// `io.write(...)`. C: `io_write`.
1308///
1309/// Writes all arguments to the current default output file (`IO_OUTPUT`). When
1310/// a file was set via `io.output(filename)`, writes go to that file; otherwise
1311/// they go to stdout via `state.write_output()`.
1312///
1313/// The borrow split (needing both `&mut LuaState` and `&mut dyn LuaFileHandle`)
1314/// is resolved by collecting all formatted strings first and then writing them
1315/// to the file handle obtained from the `LSTREAM_REGISTRY`.
1316pub fn io_write(state: &mut LuaState) -> Result<usize, LuaError> {
1317    // Step 1: collect all formatted byte strings before touching the file handle.
1318    let n = state.top();
1319    let mut chunks: Vec<Vec<u8>> = Vec::with_capacity(n as usize);
1320    for i in 1..=(n as i32) {
1321        if state.type_at(i) == LuaType::Number {
1322            let val = state.value_at(i);
1323            chunks.push(num_to_write_bytes(state, &val)?);
1324        } else {
1325            let bytes: Vec<u8> = state.check_arg_string(i)?;
1326            chunks.push(bytes);
1327        }
1328    }
1329
1330    // Step 2: resolve the current output file. C's `getiofile` errors when
1331    // the default output is closed; do not silently fall back to stdout.
1332    let p_rc = get_io_file_rc(state, IO_OUTPUT_KEY)?;
1333    {
1334        let mut p = p_rc.borrow_mut();
1335        let fh = p.file.as_mut().expect("open stream has no file handle");
1336        for chunk in &chunks {
1337            fh.write_bytes(chunk)
1338                .map_err(|e| LuaError::runtime(format_args!("io.write: {}", e)))?;
1339        }
1340    }
1341    state.registry_get(IO_OUTPUT_KEY)?;
1342    Ok(1)
1343}
1344
1345/// `file:write(...)`. C: `f_write`.
1346pub fn f_write(state: &mut LuaState) -> Result<usize, LuaError> {
1347    let p_rc = tofile(state)?;
1348
1349    // Step 1: collect args 2..=n as owned byte chunks before borrowing the file.
1350    let n = state.top();
1351    let mut chunks: Vec<Vec<u8>> = Vec::with_capacity(n.saturating_sub(1) as usize);
1352    for i in 2..=(n as i32) {
1353        if state.type_at(i) == LuaType::Number {
1354            let val = state.value_at(i);
1355            chunks.push(num_to_write_bytes(state, &val)?);
1356        } else {
1357            let bytes: Vec<u8> = state.check_arg_string(i)?;
1358            chunks.push(bytes);
1359        }
1360    }
1361
1362    // Step 2: write through the file with the LStream borrow scoped tightly.
1363    let result: io::Result<()> = {
1364        let mut p = p_rc.borrow_mut();
1365        let fh = p.file.as_mut().expect("open stream has no file handle");
1366        let mut r: io::Result<()> = Ok(());
1367        for chunk in &chunks {
1368            match fh.write_bytes(chunk) {
1369                Ok(written) if written == chunk.len() => {}
1370                Ok(_) => {
1371                    r = Err(io::Error::new(io::ErrorKind::Other, "short write"));
1372                    break;
1373                }
1374                Err(e) => {
1375                    r = Err(e);
1376                    break;
1377                }
1378            }
1379        }
1380        r
1381    };
1382
1383    // Step 3: on success return the file handle (arg 1); on failure use file_result.
1384    match result {
1385        Ok(()) => {
1386            state.push_value_at(1)?;
1387            Ok(1)
1388        }
1389        Err(e) => file_result(state, false, None, e),
1390    }
1391}
1392
1393// ── Seek / setvbuf / flush ───────────────────────────────────────────────────
1394
1395/// `file:seek([whence [, offset]])`. C: `f_seek`.
1396pub fn f_seek(state: &mut LuaState) -> Result<usize, LuaError> {
1397    static MODE_NAMES: &[&[u8]] = &[b"set", b"cur", b"end"];
1398
1399    let p_rc = tofile(state)?;
1400    let op = state.check_arg_option(2, Some(b"cur"), MODE_NAMES)?;
1401    let p3: i64 = state.opt_arg_integer(3, 0)?;
1402
1403    let seek_pos = match op {
1404        0 => SeekFrom::Start(p3 as u64),
1405        1 => SeekFrom::Current(p3),
1406        2 => SeekFrom::End(p3),
1407        _ => unreachable!(),
1408    };
1409
1410    let result = {
1411        let mut p = p_rc.borrow_mut();
1412        let fh = p.file.as_mut().expect("open stream has no file handle");
1413        fh.seek(seek_pos)
1414    };
1415    match result {
1416        Ok(pos) => {
1417            state.push(LuaValue::Int(pos as i64));
1418            Ok(1)
1419        }
1420        Err(e) => file_result(state, false, None, e),
1421    }
1422}
1423
1424/// `file:setvbuf(mode [, size])`. C: `f_setvbuf`.
1425pub fn f_setvbuf(state: &mut LuaState) -> Result<usize, LuaError> {
1426    static MODE_NAMES: &[&[u8]] = &[b"no", b"full", b"line"];
1427
1428    let p_rc = tofile(state)?;
1429    let op = state.check_arg_option(2, None, MODE_NAMES)?;
1430    let sz: i64 = state.opt_arg_integer(3, LUAL_BUFFER_SIZE as i64)?;
1431    let mode = match op {
1432        0 => BufMode::No,
1433        1 => BufMode::Full,
1434        2 => BufMode::Line,
1435        _ => unreachable!(),
1436    };
1437    let result = {
1438        let mut p = p_rc.borrow_mut();
1439        let fh = p.file.as_mut().expect("open stream has no file handle");
1440        let mode_index = match mode {
1441            BufMode::No => 0,
1442            BufMode::Full => 1,
1443            BufMode::Line => 2,
1444        };
1445        fh.set_buf_mode(mode_index, sz.max(0) as usize)
1446    };
1447    match result {
1448        Ok(()) => file_result(state, true, None, io::Error::last_os_error()),
1449        Err(e) => file_result(state, false, None, e),
1450    }
1451}
1452
1453/// `io.flush()`. C: `io_flush`.
1454pub fn io_flush(state: &mut LuaState) -> Result<usize, LuaError> {
1455    let ud_id: Option<usize> = {
1456        state.registry_get(IO_OUTPUT_KEY)?;
1457        let id = state
1458            .test_arg_userdata(-1, LUA_FILE_HANDLE)
1459            .map(|ud| ud.identity());
1460        state.pop_n(1);
1461        id
1462    };
1463    if let Some(id) = ud_id {
1464        if let Some(rc) = lookup_lstream(id) {
1465            let result = {
1466                let mut p = rc.borrow_mut();
1467                if p.is_closed() {
1468                    return Err(LuaError::runtime(format_args!(
1469                        "default output file is closed"
1470                    )));
1471                }
1472                let fh = p
1473                    .file
1474                    .as_deref_mut()
1475                    .expect("open stream has no file handle");
1476                fh.flush()
1477            };
1478            return match result {
1479                Ok(()) => {
1480                    state.push(LuaValue::Bool(true));
1481                    Ok(1)
1482                }
1483                Err(e) => file_result(state, false, None, e),
1484            };
1485        }
1486    }
1487    // No live default output file: behave like a successful no-op flush of stdout.
1488    state.push(LuaValue::Bool(true));
1489    Ok(1)
1490}
1491
1492/// `file:flush()`. C: `f_flush`.
1493pub fn f_flush(state: &mut LuaState) -> Result<usize, LuaError> {
1494    let p_rc = tofile(state)?;
1495    let result = {
1496        let mut p = p_rc.borrow_mut();
1497        let fh = p.file.as_mut().expect("open stream has no file handle");
1498        fh.flush()
1499    };
1500    match result {
1501        Ok(()) => {
1502            state.push(LuaValue::Bool(true));
1503            Ok(1)
1504        }
1505        Err(e) => file_result(state, false, None, e),
1506    }
1507}
1508
1509// ── Lines iterator ───────────────────────────────────────────────────────────
1510
1511/// Build the `io_readline` closure with its upvalues and push it.
1512///
1513/// Upvalue layout (C comment):
1514///   1) file handle (first stack value)
1515///   2) number of read-format arguments
1516///   3) toclose flag (bool)
1517///   4..n+3) format arguments
1518fn aux_lines(state: &mut LuaState, toclose: bool) -> Result<(), LuaError> {
1519    // `lua_gettop` is the stack count RELATIVE to the current frame, not the
1520    // absolute `top_idx`; using `state.top()` mirrors that.
1521    let n = state.top() - 1;
1522    if n > MAX_ARG_LINE as i32 {
1523        return Err(lua_vm::debug::arg_error_impl(
1524            state,
1525            MAX_ARG_LINE as i32 + 2,
1526            b"too many arguments",
1527        ));
1528    }
1529    state.push_value_at(1)?;
1530    state.push(LuaValue::Int(n as i64));
1531    state.push(LuaValue::Bool(toclose));
1532    state.rotate(2, 3)?;
1533    state.push_c_closure(io_readline, (3 + n) as i32)?;
1534    Ok(())
1535}
1536
1537/// `file:lines(...)`. C: `f_lines`.
1538pub fn f_lines(state: &mut LuaState) -> Result<usize, LuaError> {
1539    let _ = tofile(state)?; // validates file is open
1540    aux_lines(state, false)?;
1541    Ok(1)
1542}
1543
1544/// `io.lines([filename, ...])`. C: `io_lines`.
1545pub fn io_lines(state: &mut LuaState) -> Result<usize, LuaError> {
1546    if state.type_at(1) == LuaType::None {
1547        state.push(LuaValue::Nil);
1548    }
1549    let toclose = if state.type_at(1) == LuaType::Nil {
1550        state.registry_get(IO_INPUT_KEY)?;
1551        state.replace(1)?;
1552        let _ = tofile(state)?;
1553        false
1554    } else {
1555        let filename = state.check_arg_string(1)?;
1556        opencheck(state, &filename, b"r")?;
1557        state.replace(1)?;
1558        true
1559    };
1560
1561    aux_lines(state, toclose)?;
1562
1563    if toclose {
1564        state.push(LuaValue::Nil); // state
1565        state.push(LuaValue::Nil); // control
1566        state.push_value_at(1)?; // file as to-be-closed variable (4th result)
1567        Ok(4)
1568    } else {
1569        Ok(1)
1570    }
1571}
1572
1573/// Iteration function created by `aux_lines`. C: `io_readline`.
1574///
1575/// Upvalue layout matches what `aux_lines` creates:
1576///   upvalue 1: file handle (userdata)
1577///   upvalue 2: n (number of read-format args)
1578///   upvalue 3: toclose flag
1579///   upvalue 4..n+3: format arguments
1580fn io_readline(state: &mut LuaState) -> Result<usize, LuaError> {
1581    let n = match state.value_at(crate::state_stub::upvalue_index(2)) {
1582        LuaValue::Int(i) => i as usize,
1583        _ => 0,
1584    };
1585
1586    let p_rc = lstream_from_upvalue(state, 1)?;
1587
1588    if p_rc.borrow().is_closed() {
1589        return Err(LuaError::runtime(format_args!("file is already closed")));
1590    }
1591
1592    lua_vm::api::set_top(state, 1)?;
1593    state.ensure_stack(n as i32, "too many arguments")?;
1594
1595    for i in 1..=n {
1596        let uv = state.value_at(crate::state_stub::upvalue_index(3 + i as i32));
1597        state.push(uv);
1598    }
1599
1600    let result_n: usize = g_read(state, &p_rc, 2)?;
1601
1602    debug_assert!(result_n > 0, "g_read should return at least one value");
1603
1604    let top = state.top_idx().get() as i32;
1605    let first_result_idx = top - result_n as i32;
1606    let first_truthy = !matches!(
1607        state.stack_at(first_result_idx),
1608        LuaValue::Nil | LuaValue::Bool(false)
1609    );
1610    if first_truthy {
1611        return Ok(result_n);
1612    }
1613
1614    if result_n > 1 {
1615        let err_val = state.stack_at(first_result_idx + 1).clone();
1616        return Err(LuaError::from_value(err_val));
1617    }
1618
1619    let toclose = !matches!(
1620        state.value_at(crate::state_stub::upvalue_index(3)),
1621        LuaValue::Nil | LuaValue::Bool(false)
1622    );
1623    if toclose {
1624        lua_vm::api::set_top(state, 0)?;
1625        state.push_upvalue(1)?;
1626        aux_close(state)?;
1627    }
1628
1629    Ok(0)
1630}
1631
1632// ── Module registration ──────────────────────────────────────────────────────
1633
1634/// Create the file-handle metatable in the registry. C: `createmeta(L)`.
1635fn create_meta(state: &mut LuaState) -> Result<(), LuaError> {
1636    state.new_metatable(LUA_FILE_HANDLE)?;
1637    state.set_funcs(FILE_METAMETHODS, 0)?;
1638    state.new_lib_table(FILE_METHODS)?;
1639    state.set_funcs(FILE_METHODS, 0)?;
1640    state.set_field(-2, b"__index")?;
1641    state.pop_n(1);
1642    Ok(())
1643}
1644
1645/// Register stdin, stdout, or stderr as a Lua file handle. C: `createstdfile`.
1646fn create_std_file(
1647    state: &mut LuaState,
1648    std_kind: StdFileKind,
1649    registry_key: Option<&[u8]>,
1650    field_name: &[u8],
1651) -> Result<(), LuaError> {
1652    let cell = new_pre_file(state)?;
1653    let output_hook = match std_kind {
1654        StdFileKind::Stdout => state.global().stdout_hook,
1655        StdFileKind::Stderr => state.global().stderr_hook,
1656        StdFileKind::Stdin => None,
1657    };
1658    let input_hook = match std_kind {
1659        StdFileKind::Stdin => state.global().stdin_hook,
1660        StdFileKind::Stdout | StdFileKind::Stderr => None,
1661    };
1662    {
1663        let mut p = cell.borrow_mut();
1664        p.file = Some(Box::new(StdStreamHandle::new(
1665            std_kind,
1666            input_hook,
1667            output_hook,
1668        )));
1669        p.close_fn = Some(io_noclose);
1670    }
1671    if let Some(key) = registry_key {
1672        state.push_value_at(-1)?;
1673        state.registry_set(key)?;
1674    }
1675    state.set_field(-2, field_name)?;
1676    Ok(())
1677}
1678
1679/// Open the `io` library and return 1 (the library table). C: `luaopen_io`.
1680pub fn luaopen_io(state: &mut LuaState) -> Result<usize, LuaError> {
1681    state.new_lib(IO_LIB)?;
1682    create_meta(state)?;
1683    create_std_file(state, StdFileKind::Stdin, Some(IO_INPUT_KEY), b"stdin")?;
1684    create_std_file(state, StdFileKind::Stdout, Some(IO_OUTPUT_KEY), b"stdout")?;
1685    create_std_file(state, StdFileKind::Stderr, None, b"stderr")?;
1686    Ok(1)
1687}
1688
1689// ────────────────────────────────────────────────────────────────────────────
1690// PORT STATUS
1691//   source:        src/liolib.c  (841 lines, ~35 functions)
1692//   target_crate:  lua-stdlib
1693//   confidence:    medium
1694//   todos:         62
1695//   port_notes:    2
1696//   unsafe_blocks: 0   (must be 0 outside explicit unsafe-budget crates)
1697//   notes:         Logic faithfully translated. Phase F closed the io_readline
1698//                  is_closed/g_read stubs via lstream_from_upvalue (looks up
1699//                  the LStream side-table from the GcRef<LuaUserData> sitting
1700//                  at upvalue 1). io.popen is now wired through a new
1701//                  GlobalState::popen_hook (mirrors file_open_hook): the
1702//                  lua-cli backend spawns /bin/sh -c <cmd> and wraps the
1703//                  resulting ChildStdout/ChildStdin in a PopenFile so the
1704//                  existing LStream read/write/close path Just Works. With
1705//                  no hook registered (sandboxed embeddings) io.popen
1706//                  returns nil, errmsg, errno via file_result rather than
1707//                  panicking. stdout/stderr can now route through host output
1708//                  hooks; native builds retain a direct stdio fallback.
1709//                  io.tmpfile now uses the temp-name host hook when installed
1710//                  and fails cleanly under bare WASM without one.
1711//                  Remaining systemic Phase B blockers:
1712//                  (1) The borrow checker prevents holding &mut dyn LuaFileOps
1713//                  (extracted from LuaUserData) and &mut LuaState simultaneously;
1714//                  fix via RefCell<Box<dyn LuaFileOps>> inside LStream, plus
1715//                  restructure g_read/g_write to accept StackIdx not a raw borrow.
1716//                  (2) C's %.14g (significant-digit float format) has no direct
1717//                  Rust equivalent; a custom formatter is needed for faithful
1718//                  number serialisation. The typed-userdata API (needed to cast
1719//                  raw LuaUserData bytes to LStream) must also land in Phase B.
1720//                  rustc self-check shows only expected E0432/E0433 import errors.
1721// ────────────────────────────────────────────────────────────────────────────