Skip to main content

lua_stdlib/
io_lib.rs

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