Skip to main content

lua_stdlib/
io_lib.rs

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