Skip to main content

lua_stdlib/
io_lib.rs

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