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