Skip to main content

lua_stdlib/
loadlib.rs

1//! Dynamic library loader for the Lua `package` library.
2//!
3//! Ported from `reference/lua-5.4.7/src/loadlib.c` (758 lines, ~25 functions).
4//!
5//! Provides `require`, `package.loadlib`, `package.searchpath`, and the four
6//! built-in module searchers (preload, Lua-file, C-library, C-root).
7//!
8//! ## Platform-specific dynamic loading
9//!
10//! The three platform calls (`lsys_load`, `lsys_sym`, `lsys_unloadlib`) are
11//! dispatched through embedder hooks on [`lua_vm::state::GlobalState`]:
12//! `dynlib_load_hook`, `dynlib_symbol_hook`, `dynlib_unload_hook`. `lua-cli`
13//! installs a `libloading`-backed implementation; embeddings that omit the
14//! hooks behave like C-Lua's fallback platform stub (`LIB_FAIL = "absent"`).
15//!
16//! Keeping the platform calls behind hooks lets `lua-stdlib` stay free of
17//! `unsafe` per PORTING.md §1; `libloading` lives entirely in `lua-cli`.
18
19use std::env;
20
21use lua_types::{
22    GcRef, LuaClosure, LuaError, LuaString, LuaType, LuaValue, StackIdx, LuaStatus,
23};
24use lua_types::value::LuaTable;
25use lua_vm::state::{DynLibId, DynamicSymbol};
26use crate::state_stub::{LuaState, LuaStateStubExt as _, lua_CFunction, upvalue_index, CompareOp, LuaDebug};
27
28// ── Module-level constants ────────────────────────────────────────────────────
29
30// C: #define LUA_POF "luaopen_"
31const LUA_POF: &[u8] = b"luaopen_";
32
33// C: #define LUA_OFSEP "_"
34const LUA_OFSEP: &[u8] = b"_";
35
36// C: static const char *const CLIBS = "_CLIBS";
37const CLIBS: &[u8] = b"_CLIBS";
38
39// C: #define LIB_FAIL "open" (POSIX/Windows) or "absent" (fallback stub).
40// `lsys_load` chooses the tag at runtime: `"open"` when a load hook is
41// installed (matching POSIX/Windows behaviour) and `"absent"` when no hook
42// is registered (matching the fallback stub). The constant below carries the
43// fallback-stub spelling; the load-hook path uses `b"open"` directly.
44const LIB_FAIL_ABSENT: &[u8] = b"absent";
45
46// C: LUA_PATH_SEP — path-list separator
47const LUA_PATH_SEP: u8 = b';';
48
49// C: LUA_PATH_MARK — wildcard character in path templates
50const LUA_PATH_MARK: u8 = b'?';
51
52// C: LUA_IGMARK — ignore-mark in C module names
53const LUA_IGMARK: u8 = b'-';
54
55// C: LUA_DIRSEP — directory separator (platform-specific)
56#[cfg(target_os = "windows")]
57const LUA_DIRSEP: u8 = b'\\';
58#[cfg(not(target_os = "windows"))]
59const LUA_DIRSEP: u8 = b'/';
60
61// C: LUA_CSUBSEP / LUA_LSUBSEP — subseparators when mapping module names to paths
62// Both default to LUA_DIRSEP on all platforms.
63const LUA_CSUBSEP: u8 = LUA_DIRSEP;
64const LUA_LSUBSEP: u8 = LUA_DIRSEP;
65
66// C: #define ERRLIB 1  / #define ERRFUNC 2
67// In the Rust port these became enum variants of `LookForFuncStatus` so the
68// failure-tag string travels with the status (the C code always uses the
69// single compile-time `LIB_FAIL`). See `LookForFuncStatus` below.
70
71// C: DLMSG (fallback platform stub message). Used when no dynlib load hook
72// is registered on `GlobalState`. The CLI backend supplies its own error
73// strings via the hook's `Err` return for "open" failures.
74const DLMSG: &[u8] = b"dynamic libraries not enabled; check your Lua installation";
75
76// Message returned via `(false, msg, "init")` when a hook resolves a symbol
77// against stock Lua 5.4's `lua_State *` C ABI. That ABI is not callable
78// against this build's `LuaState`; supporting it is a separate compatibility
79// project (see docs/LUA_PHASE_E_RUNTIME_SPEC.md Part 3).
80const C_ABI_UNSUPPORTED_MSG: &[u8] =
81    b"dynamic library loaded, but Lua C ABI modules are not supported by this build";
82
83// C: #define LUA_PATH_VAR "LUA_PATH" / LUA_CPATH_VAR "LUA_CPATH"
84const LUA_PATH_VAR: &[u8] = b"LUA_PATH";
85const LUA_CPATH_VAR: &[u8] = b"LUA_CPATH";
86
87// C: LUA_PATH_DEFAULT / LUA_CPATH_DEFAULT (from luaconf.h, platform-dependent)
88// Matches C-Lua's luaconf.h defaults exactly: LUA_LDIR entries first, then
89// LUA_CDIR entries, then the local ./? fallback last.
90// TODO(port): These should come from a platform configuration crate, not be
91// hardcoded. Lua's build system inserts the actual install prefix here.
92#[cfg(not(target_os = "windows"))]
93const LUA_PATH_DEFAULT: &[u8] = b"/usr/local/share/lua/5.4/?.lua;/usr/local/share/lua/5.4/?/init.lua;/usr/local/lib/lua/5.4/?.lua;/usr/local/lib/lua/5.4/?/init.lua;./?.lua;./?/init.lua";
94#[cfg(target_os = "windows")]
95const LUA_PATH_DEFAULT: &[u8] = b"./?.lua;./?/init.lua";
96
97#[cfg(not(target_os = "windows"))]
98const LUA_CPATH_DEFAULT: &[u8] =
99    b"/usr/local/lib/lua/5.4/?.so;/usr/local/lib/lua/5.4/loadall.so;./?.so";
100#[cfg(target_os = "windows")]
101const LUA_CPATH_DEFAULT: &[u8] = b"./?.dll";
102
103// C: LUA_VERSUFFIX (from luaconf.h) — e.g. "_5_4"
104// TODO(port): Centralise version constants; this is duplicated from luaconf.h.
105const LUA_VERSUFFIX: &[u8] = b"_5_4";
106
107// ── Opaque library handle ─────────────────────────────────────────────────────
108//
109// C: `void *lib` (the return value of dlopen / LoadLibraryEx).
110//
111// In this port, the library identity is the opaque `DynLibId(u64)` allocated
112// by the embedder-installed [`DynLibLoadHook`]. `lua-stdlib` never inspects
113// the value; it stashes the raw `u64` in `_CLIBS` as light userdata (cast
114// through `*mut c_void` to match C-Lua's representation) and hands it back to
115// the symbol and unload hooks.
116
117// ── Byte-string utilities ─────────────────────────────────────────────────────
118
119/// Append to `buf` the bytes of `s` with all non-overlapping occurrences of
120/// `pattern` replaced by `replacement`.
121///
122/// C: equivalent of the substitution logic inside `luaL_gsub` / `luaL_addgsub`.
123fn gsub_append(buf: &mut Vec<u8>, s: &[u8], pattern: &[u8], replacement: &[u8]) {
124    if pattern.is_empty() {
125        buf.extend_from_slice(s);
126        return;
127    }
128    let mut pos = 0;
129    while pos < s.len() {
130        if s[pos..].starts_with(pattern) {
131            buf.extend_from_slice(replacement);
132            pos += pattern.len();
133        } else {
134            buf.push(s[pos]);
135            pos += 1;
136        }
137    }
138}
139
140/// Return a new `Vec<u8>` with all non-overlapping occurrences of `pattern`
141/// in `s` replaced by `replacement`.
142fn gsub_bytes(s: &[u8], pattern: &[u8], replacement: &[u8]) -> Vec<u8> {
143    let mut out = Vec::new();
144    gsub_append(&mut out, s, pattern, replacement);
145    out
146}
147
148/// Find the byte offset of `needle` in `haystack`, or `None`.
149fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
150    if needle.is_empty() {
151        return Some(0);
152    }
153    haystack.windows(needle.len()).position(|w| w == needle)
154}
155
156// ── Platform-specific dynamic-loading dispatch ────────────────────────────────
157
158/// Unload a previously loaded C library.
159///
160/// C: `static void lsys_unloadlib(void *lib)`
161///    — POSIX: `dlclose(lib)`; Windows: `FreeLibrary(lib)`.
162///
163/// Delegates to [`GlobalState::dynlib_unload_hook`]. When no hook is
164/// registered the library is leaked, which matches `libloading`'s safety
165/// model (the library must outlive every symbol it exports, and the simplest
166/// correct policy is to keep it alive for the state's lifetime).
167fn lsys_unloadlib(state: &mut LuaState, lib: DynLibId) {
168    if let Some(hook) = state.global().dynlib_unload_hook {
169        hook(lib);
170    }
171}
172
173/// Load a C library from `path`. If `see_glb` is true, make symbols globally
174/// visible (POSIX RTLD_GLOBAL). On failure, pushes an error string onto `state`.
175///
176/// C: `static void *lsys_load(lua_State *L, const char *path, int seeglb)`
177///    — POSIX: `dlopen(path, RTLD_NOW | (seeglb ? RTLD_GLOBAL : RTLD_LOCAL))`
178///    — Windows: `LoadLibraryExA(path, NULL, LUA_LLE_FLAGS)`
179///
180/// PORT NOTE: returns `(handle, lib_fail_tag)`. The tag is `"absent"` when no
181/// hook is registered (matching C's fallback-stub `LIB_FAIL`) and `"open"`
182/// when the hook itself reports a failure (matching POSIX/Windows builds).
183fn lsys_load(
184    state: &mut LuaState,
185    path: &[u8],
186    see_glb: bool,
187) -> (Option<DynLibId>, &'static [u8]) {
188    let hook = state.global().dynlib_load_hook;
189    let Some(load_fn) = hook else {
190        let s = match state.intern_str(DLMSG) {
191            Ok(s) => s,
192            Err(_) => return (None, LIB_FAIL_ABSENT),
193        };
194        state.push(LuaValue::Str(s));
195        return (None, LIB_FAIL_ABSENT);
196    };
197    match load_fn(state, path, see_glb) {
198        Ok(id) => (Some(id), b"open"),
199        // PORT NOTE: `LuaError::File` is reserved for "no shared library at
200        // this path". Map it to the fallback-stub `"absent"` tag so that a
201        // probe like `package.loadlib("./nonexistent.so", ...)` reports
202        // `"absent"` regardless of whether a backend is installed. Every
203        // other `Err` is a true open-time failure → `"open"`.
204        Err(LuaError::File) => {
205            let mut msg = b"cannot find library '".to_vec();
206            msg.extend_from_slice(path);
207            msg.push(b'\'');
208            let s = match state.intern_str(&msg) {
209                Ok(s) => s,
210                Err(_) => return (None, LIB_FAIL_ABSENT),
211            };
212            state.push(LuaValue::Str(s));
213            (None, LIB_FAIL_ABSENT)
214        }
215        Err(err) => {
216            let msg = error_to_bytes(&err);
217            let s = match state.intern_str(&msg) {
218                Ok(s) => s,
219                Err(_) => return (None, b"open"),
220            };
221            state.push(LuaValue::Str(s));
222            (None, b"open")
223        }
224    }
225}
226
227/// Find symbol `sym` in library `lib` and either push it as a callable Lua
228/// function (returning `SymOutcome::Found`) or push an error message string
229/// and report which failure category the caller should propagate.
230///
231/// C: `static lua_CFunction lsys_sym(lua_State *L, void *lib, const char *sym)`
232///    — POSIX: `cast_func(dlsym(lib, sym))`
233///    — Windows: `(lua_CFunction)(voidf)GetProcAddress(lib, sym)`
234fn lsys_sym(state: &mut LuaState, lib: DynLibId, sym: &[u8]) -> SymOutcome {
235    let hook = state.global().dynlib_symbol_hook;
236    let Some(sym_fn) = hook else {
237        let s = match state.intern_str(DLMSG) {
238            Ok(s) => s,
239            Err(_) => return SymOutcome::Missing,
240        };
241        state.push(LuaValue::Str(s));
242        return SymOutcome::Missing;
243    };
244    match sym_fn(state, lib, sym) {
245        Ok(DynamicSymbol::RustNative(f)) => SymOutcome::Found(f),
246        Ok(DynamicSymbol::LuaCAbi(_)) => {
247            let s = match state.intern_str(C_ABI_UNSUPPORTED_MSG) {
248                Ok(s) => s,
249                Err(_) => return SymOutcome::Missing,
250            };
251            state.push(LuaValue::Str(s));
252            SymOutcome::Missing
253        }
254        Ok(DynamicSymbol::Unsupported { reason }) => {
255            let s = match state.intern_str(&reason) {
256                Ok(s) => s,
257                Err(_) => return SymOutcome::Missing,
258            };
259            state.push(LuaValue::Str(s));
260            SymOutcome::Missing
261        }
262        Err(err) => {
263            let msg = error_to_bytes(&err);
264            let s = match state.intern_str(&msg) {
265                Ok(s) => s,
266                Err(_) => return SymOutcome::Missing,
267            };
268            state.push(LuaValue::Str(s));
269            SymOutcome::Missing
270        }
271    }
272}
273
274/// Outcome of `lsys_sym`.
275///
276/// `Missing` covers every non-success path (unknown symbol, ABI mismatch, hook
277/// absent, embedder-supplied refusal); in every case an error-message string
278/// has already been pushed onto the Lua stack, so the caller maps `Missing`
279/// to `ERRFUNC` / `"init"` without further work.
280enum SymOutcome {
281    /// Resolved to a Rust-native callable.
282    Found(lua_CFunction),
283    /// Resolution failed; an error-message string is on the stack.
284    Missing,
285}
286
287/// Extract a byte-string error message from a `LuaError`, falling back to a
288/// debug rendering for non-string variants.
289fn error_to_bytes(e: &LuaError) -> Vec<u8> {
290    match e {
291        LuaError::Runtime(LuaValue::Str(s)) | LuaError::Syntax(LuaValue::Str(s)) => {
292            s.as_bytes().to_vec()
293        }
294        other => format!("{:?}", other).into_bytes(),
295    }
296}
297
298/// Encode a [`DynLibId`] as a `*mut c_void` for storage in `_CLIBS` as light
299/// userdata. The cast is the inverse of [`decode_dynlib_id`]; neither side
300/// ever dereferences the pointer.
301fn encode_dynlib_id(id: DynLibId) -> *mut std::ffi::c_void {
302    id.0 as usize as *mut std::ffi::c_void
303}
304
305/// Decode a [`DynLibId`] previously stored via [`encode_dynlib_id`].
306fn decode_dynlib_id(p: *mut std::ffi::c_void) -> DynLibId {
307    DynLibId(p as usize as u64)
308}
309
310// ── Path helpers ──────────────────────────────────────────────────────────────
311
312/// Return `registry["LUA_NOENV"]` as a boolean.
313///
314/// C: `static int noenv(lua_State *L)`
315fn noenv(state: &mut LuaState) -> bool {
316    // C: lua_getfield(L, LUA_REGISTRYINDEX, "LUA_NOENV");
317    state.get_field_registry(b"LUA_NOENV");
318    // C: b = lua_toboolean(L, -1);
319    let b = state.to_boolean(-1);
320    // C: lua_pop(L, 1);
321    state.pop_n(1);
322    b
323}
324
325/// Set `package[fieldname]` to the appropriate path value.
326///
327/// Priority: versioned env var (e.g. `LUA_PATH_5_4`) → unversioned env var
328/// (`LUA_PATH`) → compiled-in default. When the env var contains `;;`, the
329/// compiled-in default is spliced in place of `;;`.
330///
331/// C: `static void setpath(lua_State *L, const char *fieldname,
332///                          const char *envname, const char *dft)`
333///
334/// PORT NOTE: C pushes the versioned env-var name string onto the Lua stack
335/// (via `lua_pushfstring`) and pops it at the end so that `setfield` uses index
336/// `-3`. In Rust we compute the versioned name without touching the Lua stack,
337/// so after pushing the final path value the package table is at `-2`. The
338/// caller must ensure the package table is at stack top when setpath is called.
339fn setpath(
340    state: &mut LuaState,
341    fieldname: &[u8],
342    envname: &[u8],
343    dft: &[u8],
344) -> Result<(), LuaError> {
345    // C: const char *nver = lua_pushfstring(L, "%s%s", envname, LUA_VERSUFFIX);
346    let mut nver = envname.to_vec();
347    nver.extend_from_slice(LUA_VERSUFFIX);
348
349    // C: path = getenv(nver);  (then fallback to getenv(envname))
350    // TODO(port): std::env::var() accepts &str (UTF-8). Env-var names are
351    // OS-level ASCII here (not Lua user data), so from_utf8 is acceptable, but
352    // std::env::var_os + std::os::unix::ffi::OsStrExt would be more correct for
353    // paths containing non-UTF-8 bytes on Unix. Revisit in Phase B.
354    let nver_str = std::str::from_utf8(&nver).unwrap_or("");
355    let envname_str = std::str::from_utf8(envname).unwrap_or("");
356
357    let path_opt: Option<Vec<u8>> = env::var(nver_str)
358        .ok()
359        .map(|s| s.into_bytes())
360        .or_else(|| env::var(envname_str).ok().map(|s| s.into_bytes()));
361
362    let final_path: Vec<u8> = if path_opt.is_none() || noenv(state) {
363        // C: lua_pushstring(L, dft);
364        dft.to_vec()
365    } else {
366        let path = path_opt.unwrap();
367        // C: dftmark = strstr(path, LUA_PATH_SEP LUA_PATH_SEP)
368        let double_sep = [LUA_PATH_SEP, LUA_PATH_SEP];
369        if let Some(dftmark_pos) = find_subslice(&path, &double_sep) {
370            // Path contains ";;": replace with default.
371            // C: luaL_Buffer b; luaL_buffinit(L, &b);
372            let mut buf = Vec::new();
373            if dftmark_pos > 0 {
374                // C: luaL_addlstring(&b, path, dftmark - path);
375                buf.extend_from_slice(&path[..dftmark_pos]);
376                // C: luaL_addchar(&b, *LUA_PATH_SEP);
377                buf.push(LUA_PATH_SEP);
378            }
379            // C: luaL_addstring(&b, dft);
380            buf.extend_from_slice(dft);
381            let after = dftmark_pos + 2;
382            if after < path.len() {
383                // C: luaL_addchar(&b, *LUA_PATH_SEP);
384                buf.push(LUA_PATH_SEP);
385                // C: luaL_addlstring(&b, dftmark + 2, (path + len - 2) - dftmark);
386                buf.extend_from_slice(&path[after..]);
387            }
388            // C: luaL_pushresult(&b);
389            buf
390        } else {
391            // C: lua_pushstring(L, path);
392            path
393        }
394    };
395
396    // C: setprogdir(L);
397    // PORT NOTE: On Windows, setprogdir replaces LUA_EXEC_DIR in the path with
398    // the directory of the running executable (GetModuleFileNameA). On all other
399    // platforms it's a no-op ((void)0). Stubbed here; on Windows this would also
400    // require unsafe (Win32 API). The EXEC_DIR substitution is therefore skipped.
401
402    // C: lua_setfield(L, -3, fieldname);
403    // PORT NOTE: In C the index is -3 because the versioned-name string is still
404    // on the stack. In Rust it is -2 because we did not push the versioned name.
405    let s = state.intern_str(&final_path)?;
406    state.push(LuaValue::Str(s));
407    state.set_field(-2, fieldname)?;
408
409    // C: lua_pop(L, 1);  -- pop versioned variable name ('nver')
410    // PORT NOTE: No nver was pushed in Rust; nothing to pop here.
411
412    Ok(())
413}
414
415// ── CLIBS registry table ──────────────────────────────────────────────────────
416
417/// Return the library handle stored at `registry._CLIBS[path]`, or `None`.
418///
419/// C: `static void *checkclib(lua_State *L, const char *path)`
420fn checkclib(state: &mut LuaState, path: &[u8]) -> Option<DynLibId> {
421    // C: lua_getfield(L, LUA_REGISTRYINDEX, CLIBS);
422    state.get_field_registry(CLIBS);
423    // C: lua_getfield(L, -1, path);
424    state.get_field(-1, path);
425    // C: plib = lua_touserdata(L, -1);
426    let handle = state.to_light_userdata(-1).map(decode_dynlib_id);
427    // C: lua_pop(L, 2);
428    state.pop_n(2);
429    handle
430}
431
432/// Register a library handle in the CLIBS table (both by path and sequentially).
433///
434/// C: `static void addtoclib(lua_State *L, const char *path, void *plib)`
435fn addtoclib(state: &mut LuaState, path: &[u8], plib: DynLibId) -> Result<(), LuaError> {
436    // C: lua_getfield(L, LUA_REGISTRYINDEX, CLIBS);
437    state.get_field_registry(CLIBS);
438    // C: lua_pushlightuserdata(L, plib);
439    state.push(LuaValue::LightUserData(encode_dynlib_id(plib)));
440    // C: lua_pushvalue(L, -1);
441    state.push_value(-1);
442    // C: lua_setfield(L, -3, path);  -- CLIBS[path] = plib
443    state.set_field(-3, path)?;
444    // C: lua_rawseti(L, -2, luaL_len(L, -2) + 1);  -- CLIBS[#CLIBS + 1] = plib
445    let n = state.len_at(-2);
446    state.raw_seti(-2, n + 1)?;
447    // C: lua_pop(L, 1);  -- pop CLIBS table
448    state.pop_n(1);
449    Ok(())
450}
451
452/// `__gc` metamethod for the CLIBS table: unloads all registered C libraries
453/// in reverse order when the Lua state closes.
454///
455/// C: `static int gctm(lua_State *L)`
456fn gctm(state: &mut LuaState) -> Result<usize, LuaError> {
457    // C: lua_Integer n = luaL_len(L, 1);
458    let n = state.len_at(1);
459    let mut i = n;
460    // C: for (; n >= 1; n--)
461    while i >= 1 {
462        // C: lua_rawgeti(L, 1, n);  -- get handle CLIBS[n]
463        state.raw_geti(1, i)?;
464        // C: lsys_unloadlib(lua_touserdata(L, -1));
465        if let Some(handle) = state.to_light_userdata(-1).map(decode_dynlib_id) {
466            lsys_unloadlib(state, handle);
467        }
468        // C: lua_pop(L, 1);
469        state.pop_n(1);
470        i -= 1;
471    }
472    Ok(0)
473}
474
475// ── Dynamic function lookup ───────────────────────────────────────────────────
476
477/// Look for a C function named `sym` in the dynamically loaded library at `path`.
478///
479/// On success, pushes the C function (or `true` for `*`-sentinel) and returns `Ok(0)`.
480/// On non-fatal failure, pushes an error message string and returns `Ok(ERRLIB)`
481/// or `Ok(ERRFUNC)`. Fatal errors (e.g. OOM) propagate via `Err`.
482///
483/// C: `static int lookforfunc(lua_State *L, const char *path, const char *sym)`
484///
485/// PORT NOTE: C returns raw `int` error codes. Rust encodes them as `Ok(i32)`
486/// so the caller can distinguish "error code + message on stack" from "fatal Err".
487/// Status of `lookforfunc`. `Ok(0)` corresponds to C's `0` "success",
488/// `ErrLib(tag)` to C's `ERRLIB` (tag is the `LIB_FAIL` string the caller
489/// should attach: `"open"` for true dlopen failures, `"absent"` when no
490/// backend or the file doesn't exist), `ErrFunc` to C's `ERRFUNC`.
491enum LookForFuncStatus {
492    /// Loader successfully resolved a symbol (function pushed on stack).
493    Ok,
494    /// Library could not be opened. `tag` is the `LIB_FAIL` string.
495    ErrLib(&'static [u8]),
496    /// Library opened but symbol could not be resolved.
497    ErrFunc,
498}
499
500fn lookforfunc(
501    state: &mut LuaState,
502    path: &[u8],
503    sym: &[u8],
504) -> Result<LookForFuncStatus, LuaError> {
505    // C: void *reg = checkclib(L, path);
506    let reg = match checkclib(state, path) {
507        Some(handle) => handle,
508        None => {
509            // C: reg = lsys_load(L, path, *sym == '*');
510            let (loaded, tag) = lsys_load(state, path, sym.first() == Some(&b'*'));
511            match loaded {
512                Some(handle) => {
513                    addtoclib(state, path, handle)?;
514                    handle
515                }
516                // C: if (reg == NULL) return ERRLIB;
517                None => return Ok(LookForFuncStatus::ErrLib(tag)),
518            }
519        }
520    };
521    // C: if (*sym == '*') { lua_pushboolean(L, 1); return 0; }
522    if sym.first() == Some(&b'*') {
523        state.push(LuaValue::Bool(true));
524        return Ok(LookForFuncStatus::Ok);
525    }
526    // C: lua_CFunction f = lsys_sym(L, reg, sym);
527    match lsys_sym(state, reg, sym) {
528        SymOutcome::Found(func) => {
529            // C: lua_pushcfunction(L, f);
530            state.push_c_function(func)?;
531            Ok(LookForFuncStatus::Ok)
532        }
533        // C: return ERRFUNC;
534        SymOutcome::Missing => Ok(LookForFuncStatus::ErrFunc),
535    }
536}
537
538// ── Lua-callable package functions ────────────────────────────────────────────
539
540/// `package.loadlib(filename, funcname)` — open a C library and return a
541/// Lua-callable wrapper for `funcname`.
542///
543/// Returns: on success, the loader function (1 value).
544/// On error: `false`, error-message string, and `"open"` or `"init"` (3 values).
545///
546/// C: `static int ll_loadlib(lua_State *L)`
547pub fn ll_loadlib(state: &mut LuaState) -> Result<usize, LuaError> {
548    // C: const char *path = luaL_checkstring(L, 1);
549    let path = state.check_arg_string(1)?.to_vec();
550    // C: const char *init = luaL_checkstring(L, 2);
551    let init = state.check_arg_string(2)?.to_vec();
552    // C: int stat = lookforfunc(L, path, init);
553    let stat = lookforfunc(state, &path, &init)?;
554    // C: if (l_likely(stat == 0)) return 1;
555    let where_bytes: &[u8] = match stat {
556        LookForFuncStatus::Ok => return Ok(1),
557        LookForFuncStatus::ErrLib(tag) => tag,
558        LookForFuncStatus::ErrFunc => b"init",
559    };
560    // C: luaL_pushfail(L);
561    // PORT NOTE: luaL_pushfail pushes `false` in Lua 5.4 (changed from nil).
562    state.push(LuaValue::Bool(false));
563    // C: lua_insert(L, -2);  -- move fail below error message
564    state.insert(-2);
565    // C: lua_pushstring(L, (stat == ERRLIB) ? LIB_FAIL : "init");
566    //
567    // PORT NOTE: the `LIB_FAIL` tag is chosen at run time. The CLI backend
568    // reports `LuaError::File` for a missing library → `"absent"` (matching
569    // C-Lua's no-dlfcn fallback); a true `dlopen` failure → `"open"`. The
570    // "init" branch (symbol resolution failed after the library opened) is
571    // identical in every build.
572    let where_s = state.intern_str(where_bytes)?;
573    state.push(LuaValue::Str(where_s));
574    // C: return 3;
575    Ok(3)
576}
577
578// ── File existence check ──────────────────────────────────────────────────────
579
580/// Try to open `filename` for reading; return `true` if it succeeds.
581///
582/// C: `static int readable(const char *filename)`
583///    — `FILE *f = fopen(filename, "r"); if (f == NULL) return 0;`
584///
585/// PORT NOTE: `std::fs` is banned in `lua-stdlib`, so the actual file probe is
586/// delegated to the embedder-registered `file_loader_hook` on `GlobalState`.
587/// Without a hook installed, `readable` reports `false` (file system unreachable).
588fn readable(state: &LuaState, filename: &[u8]) -> bool {
589    match state.global().file_loader_hook {
590        Some(hook) => hook(filename).is_ok(),
591        None => false,
592    }
593}
594
595// ── Path-component iterator ───────────────────────────────────────────────────
596
597/// Iterator over `;`-separated path components.
598///
599/// C: `getnextfilename(char **path, char *end)` advanced a mutable pointer
600/// through a buffer, temporarily zero-terminating each component. In Rust we
601/// advance a slice reference without mutation.
602///
603/// PORT NOTE: The C implementation restored each separator after use (mutating
604/// the buffer). This Rust version slices immutably, which changes the interface
605/// but produces the same sequence of filenames.
606struct PathComponents<'a> {
607    remaining: &'a [u8],
608}
609
610impl<'a> PathComponents<'a> {
611    fn new(path: &'a [u8]) -> Self {
612        PathComponents { remaining: path }
613    }
614}
615
616impl<'a> Iterator for PathComponents<'a> {
617    type Item = &'a [u8];
618
619    fn next(&mut self) -> Option<Self::Item> {
620        if self.remaining.is_empty() {
621            return None;
622        }
623        let component = match self.remaining.iter().position(|&b| b == LUA_PATH_SEP) {
624            Some(sep_pos) => {
625                let c = &self.remaining[..sep_pos];
626                self.remaining = &self.remaining[sep_pos + 1..];
627                c
628            }
629            None => {
630                let c = self.remaining;
631                self.remaining = &[];
632                c
633            }
634        };
635        Some(component)
636    }
637}
638
639// ── Error-message helpers ─────────────────────────────────────────────────────
640
641/// Push an error message listing all files in `path` that were not found.
642///
643/// Example output: `"no file 'a.lua'\n\tno file 'b.lua'"`
644///
645/// C: `static void pusherrornotfound(lua_State *L, const char *path)`
646fn pusherrornotfound(state: &mut LuaState, path: &[u8]) -> Result<(), LuaError> {
647    // C: luaL_Buffer b; luaL_buffinit(L, &b);
648    let mut buf: Vec<u8> = Vec::new();
649    // C: luaL_addstring(&b, "no file '");
650    buf.extend_from_slice(b"no file '");
651    // C: luaL_addgsub(&b, path, LUA_PATH_SEP, "'\n\tno file '");
652    gsub_append(&mut buf, path, &[LUA_PATH_SEP], b"'\n\tno file '");
653    // C: luaL_addstring(&b, "'");
654    buf.push(b'\'');
655    // C: luaL_pushresult(&b);
656    let s = state.intern_str(&buf)?;
657    state.push(LuaValue::Str(s));
658    Ok(())
659}
660
661// ── Path search ───────────────────────────────────────────────────────────────
662
663/// Search for a readable file matching `name` in the `;`-separated `path`.
664///
665/// In each path template, `?` is replaced by `name` (with `sep` bytes replaced
666/// by `dirsep` first). Returns `Some(filename_bytes)` and pushes the filename
667/// string on the Lua stack if found. Returns `None` and pushes an error message
668/// string if not found.
669///
670/// C: `static const char *searchpath(lua_State *L, const char *name,
671///                                    const char *path, const char *sep,
672///                                    const char *dirsep)`
673fn searchpath(
674    state: &mut LuaState,
675    name: &[u8],
676    path: &[u8],
677    sep: &[u8],
678    dirsep: &[u8],
679) -> Result<Option<Vec<u8>>, LuaError> {
680    // C: if (*sep != '\0' && strchr(name, *sep) != NULL)
681    //        name = luaL_gsub(L, name, sep, dirsep);
682    let name_buf: Vec<u8> = if !sep.is_empty() && name.contains(&sep[0]) {
683        gsub_bytes(name, sep, dirsep)
684    } else {
685        name.to_vec()
686    };
687
688    // C: luaL_buffinit(L, &buff);
689    // C: luaL_addgsub(&buff, path, LUA_PATH_MARK, name);
690    // Build pathname list: replace every '?' in path with the (adjusted) name.
691    let pathname: Vec<u8> = gsub_bytes(path, &[LUA_PATH_MARK], &name_buf);
692
693    // C: while ((filename = getnextfilename(&pathname, endpathname)) != NULL)
694    for filename in PathComponents::new(&pathname) {
695        // C: if (readable(filename)) return lua_pushstring(L, filename);
696        if readable(state, filename) {
697            let s = state.intern_str(filename)?;
698            state.push(LuaValue::Str(s));
699            return Ok(Some(filename.to_vec()));
700        }
701    }
702
703    // C: luaL_pushresult(&buff);          -- push expanded path list
704    // C: pusherrornotfound(L, lua_tostring(L, -1));
705    // PORT NOTE: C uses the Lua-stack string of the expanded pathname as the
706    // argument to pusherrornotfound. In Rust we have `pathname` already as a
707    // Vec<u8>; we pass it directly without the round-trip through the Lua stack.
708    pusherrornotfound(state, &pathname)?;
709    Ok(None)
710}
711
712/// `package.searchpath(name, path [, sep [, rep]])`.
713///
714/// Returns the first readable file in `path` with `sep` occurrences in `name`
715/// replaced by `rep`. On failure returns `false` plus the error message.
716///
717/// C: `static int ll_searchpath(lua_State *L)`
718pub fn ll_searchpath(state: &mut LuaState) -> Result<usize, LuaError> {
719    // C: luaL_checkstring(L, 1) / luaL_checkstring(L, 2)
720    let name = state.check_arg_string(1)?.to_vec();
721    let path = state.check_arg_string(2)?.to_vec();
722    // C: luaL_optstring(L, 3, ".")
723    let sep = state.opt_arg_string(3, b".")?;
724    // C: luaL_optstring(L, 4, LUA_DIRSEP)
725    let dirsep_default = [LUA_DIRSEP];
726    let dirsep = state.opt_arg_string(4, &dirsep_default)?;
727
728    let found = searchpath(state, &name, &path, &sep, &dirsep)?;
729    if found.is_some() {
730        // C: if (f != NULL) return 1;
731        return Ok(1);
732    }
733    // C: luaL_pushfail(L); lua_insert(L, -2); return 2;
734    state.push(LuaValue::Bool(false));
735    state.insert(-2);
736    Ok(2)
737}
738
739/// Find a module file using the path stored in `package[pname]`.
740///
741/// C: `static const char *findfile(lua_State *L, const char *name,
742///                                  const char *pname, const char *dirsep)`
743fn findfile(state: &mut LuaState, name: &[u8], pname: &[u8], dirsep: u8) -> Result<Option<Vec<u8>>, LuaError> {
744    // C: lua_getfield(L, lua_upvalueindex(1), pname);
745    // The package table is upvalue #1 for the searcher closures.
746    let uv = state.upvalue_index(1);
747    let _ = state.get_field(uv, pname);
748    // C: path = lua_tostring(L, -1);
749    let path_opt: Option<Vec<u8>> = state.to_bytes(-1);
750    let Some(path) = path_opt else {
751        // C: if (l_unlikely(path == NULL)) luaL_error(L, "'package.%s' must be a string", pname);
752        state.pop_n(1);
753        return Err(LuaError::runtime(format_args!(
754            "'package.{}' must be a string",
755            String::from_utf8_lossy(pname)
756        )));
757    };
758    state.pop_n(1);
759    searchpath(state, name, &path, b".", &[dirsep])
760}
761
762/// Check whether a module load succeeded, returning the open function + filename
763/// (2 values) on success or raising an error on failure.
764///
765/// C: `static int checkload(lua_State *L, int stat, const char *filename)`
766fn checkload(state: &mut LuaState, stat: bool, filename: &[u8]) -> Result<usize, LuaError> {
767    if stat {
768        // C: lua_pushstring(L, filename);
769        let s = state.intern_str(filename)?;
770        state.push(LuaValue::Str(s));
771        // C: return 2;
772        Ok(2)
773    } else {
774        // C: return luaL_error(L, "error loading module '%s' from file '%s':\n\t%s",
775        //                         lua_tostring(L, 1), filename, lua_tostring(L, -1));
776        // PORT NOTE: The error message in C embeds the module name (stack[1]) and
777        // the loader error message (stack top). In Rust we read those byte slices.
778        // TODO(port): state.to_bytes(1) and state.to_bytes(-1) borrow from the
779        //             stack simultaneously; in Phase B use index-snapshot clones.
780        let modname = state.to_bytes(1).unwrap_or_else(|| b"?".to_vec());
781        let loader_err = state.to_bytes(-1).unwrap_or_else(|| b"?".to_vec());
782
783        let mut msg = b"error loading module '".to_vec();
784        msg.extend_from_slice(&modname);
785        msg.extend_from_slice(b"' from file '");
786        msg.extend_from_slice(filename);
787        msg.extend_from_slice(b"':\n\t");
788        msg.extend_from_slice(&loader_err);
789
790        // PERF(port): builds a heap Vec then interns; in Phase B use push_fstring.
791        let s = state.intern_str(&msg)?;
792        return Err(LuaError::from_value(LuaValue::Str(s)));
793    }
794}
795
796// ── Searcher functions ────────────────────────────────────────────────────────
797
798/// Searcher that looks in `package.path` for a Lua source file.
799///
800/// Returns 1 value (error-message string) if not found, or 2 values (loader
801/// function, filename) if found and loaded successfully.
802///
803/// C: `static int searcher_Lua(lua_State *L)`
804fn searcher_lua(state: &mut LuaState) -> Result<usize, LuaError> {
805    // C: const char *name = luaL_checkstring(L, 1);
806    let name = state.check_arg_string(1)?.to_vec();
807    // C: filename = findfile(L, name, "path", LUA_LSUBSEP);
808    let filename = findfile(state, &name, b"path", LUA_LSUBSEP)?;
809    if filename.is_none() {
810        // C: if (filename == NULL) return 1;  -- error message on stack
811        return Ok(1);
812    }
813    let filename = filename.unwrap();
814    // C: return checkload(L, (luaL_loadfile(L, filename) == LUA_OK), filename);
815    //
816    // PORT NOTE: `std::fs` is banned in `lua-stdlib`, so file contents come in
817    // via the embedder-registered `file_loader_hook` on `GlobalState`. We then
818    // parse them through `state.load(...)` (which dispatches to the parser
819    // hook) and place the resulting closure on the stack so `checkload` can
820    // pair it with the filename.
821    let chunk = match state.global().file_loader_hook {
822        Some(hook) => hook(&filename),
823        None => Err(LuaError::runtime(format_args!(
824            "no file_loader_hook registered; cannot read '{}'",
825            String::from_utf8_lossy(&filename)
826        ))),
827    };
828    let load_ok = match chunk {
829        Ok(bytes) => {
830            // Use a chunk name of the form `@filename` matching C's luaL_loadfilex.
831            let mut chunkname = b"@".to_vec();
832            chunkname.extend_from_slice(&filename);
833            match state.load(&bytes, &chunkname, None) {
834                Ok(true) => true,
835                Ok(false) => false,
836                Err(e) => {
837                    let msg = match e {
838                        LuaError::Syntax(LuaValue::Str(ref s))
839                        | LuaError::Runtime(LuaValue::Str(ref s)) => s.as_bytes().to_vec(),
840                        other => format!("{:?}", other).into_bytes(),
841                    };
842                    let s = state.intern_str(&msg)?;
843                    state.push(LuaValue::Str(s));
844                    false
845                }
846            }
847        }
848        Err(e) => {
849            let msg = match e {
850                LuaError::Runtime(LuaValue::Str(ref s)) => s.as_bytes().to_vec(),
851                other => format!("{:?}", other).into_bytes(),
852            };
853            let s = state.intern_str(&msg)?;
854            state.push(LuaValue::Str(s));
855            false
856        }
857    };
858    checkload(state, load_ok, &filename)
859}
860
861/// Try to load `modname`'s open function from the C dynamic library at `filename`.
862///
863/// Handles the "ignore mark" (`-`) convention: `"foo-bar"` first tries
864/// `luaopen_foo`, then `luaopen_bar` as a fallback.
865///
866/// C: `static int loadfunc(lua_State *L, const char *filename, const char *modname)`
867fn loadfunc(
868    state: &mut LuaState,
869    filename: &[u8],
870    modname: &[u8],
871) -> Result<LookForFuncStatus, LuaError> {
872    // C: modname = luaL_gsub(L, modname, ".", LUA_OFSEP);
873    let modname: Vec<u8> = gsub_bytes(modname, b".", LUA_OFSEP);
874
875    // C: mark = strchr(modname, *LUA_IGMARK);
876    if let Some(mark_pos) = modname.iter().position(|&b| b == LUA_IGMARK) {
877        // C: openfunc = lua_pushlstring(L, modname, mark - modname);
878        let prefix = &modname[..mark_pos];
879        // C: openfunc = lua_pushfstring(L, LUA_POF"%s", openfunc);
880        let mut openfunc = LUA_POF.to_vec();
881        openfunc.extend_from_slice(prefix);
882        // C: stat = lookforfunc(L, filename, openfunc);
883        let stat = lookforfunc(state, filename, &openfunc)?;
884        // C: if (stat != ERRFUNC) return stat;
885        if !matches!(stat, LookForFuncStatus::ErrFunc) {
886            return Ok(stat);
887        }
888        // C: modname = mark + 1;  -- else go ahead and try old-style name
889        let tail = &modname[mark_pos + 1..];
890        let mut openfunc2 = LUA_POF.to_vec();
891        openfunc2.extend_from_slice(tail);
892        return lookforfunc(state, filename, &openfunc2);
893    }
894
895    // C: openfunc = lua_pushfstring(L, LUA_POF"%s", modname);
896    let mut openfunc = LUA_POF.to_vec();
897    openfunc.extend_from_slice(&modname);
898    lookforfunc(state, filename, &openfunc)
899}
900
901/// Searcher that looks in `package.cpath` for a C dynamic library.
902///
903/// C: `static int searcher_C(lua_State *L)`
904fn searcher_c(state: &mut LuaState) -> Result<usize, LuaError> {
905    // C: const char *name = luaL_checkstring(L, 1);
906    let name = state.check_arg_string(1)?.to_vec();
907    // C: const char *filename = findfile(L, name, "cpath", LUA_CSUBSEP);
908    let filename = findfile(state, &name, b"cpath", LUA_CSUBSEP)?;
909    if filename.is_none() {
910        // C: if (filename == NULL) return 1;
911        return Ok(1);
912    }
913    let filename = filename.unwrap();
914    // C: return checkload(L, (loadfunc(L, filename, name) == 0), filename);
915    let stat = loadfunc(state, &filename, &name)?;
916    let ok = matches!(stat, LookForFuncStatus::Ok);
917    checkload(state, ok, &filename)
918}
919
920/// Searcher that looks in `package.cpath` using only the root component
921/// (everything before the first `.`) of the module name.
922///
923/// C: `static int searcher_Croot(lua_State *L)`
924fn searcher_croot(state: &mut LuaState) -> Result<usize, LuaError> {
925    // C: const char *name = luaL_checkstring(L, 1);
926    let name = state.check_arg_string(1)?.to_vec();
927    // C: const char *p = strchr(name, '.');
928    let dot_pos = name.iter().position(|&b| b == b'.');
929    if dot_pos.is_none() {
930        // C: if (p == NULL) return 0;  -- is already root; not our responsibility
931        return Ok(0);
932    }
933    let dot_pos = dot_pos.unwrap();
934
935    // C: lua_pushlstring(L, name, p - name);  -- push root portion
936    let root = &name[..dot_pos];
937    let root_s = state.intern_str(root)?;
938    state.push(LuaValue::Str(root_s));
939
940    // C: filename = findfile(L, lua_tostring(L, -1), "cpath", LUA_CSUBSEP);
941    // PORT NOTE: C reads the root string back from the stack; in Rust we use
942    // the slice directly and then pop the stack entry below.
943    let filename = findfile(state, root, b"cpath", LUA_CSUBSEP)?;
944    // Pop the root string we pushed above (findfile does not consume it).
945    state.pop_n(1);
946
947    if filename.is_none() {
948        // C: if (filename == NULL) return 1;
949        return Ok(1);
950    }
951    let filename = filename.unwrap();
952
953    // C: if ((stat = loadfunc(L, filename, name)) != 0) { ... }
954    let stat = loadfunc(state, &filename, &name)?;
955    match stat {
956        LookForFuncStatus::Ok => {}
957        LookForFuncStatus::ErrFunc => {
958            // C: lua_pushfstring(L, "no module '%s' in file '%s'", name, filename);
959            // C: return 1;
960            let mut msg = b"no module '".to_vec();
961            msg.extend_from_slice(&name);
962            msg.extend_from_slice(b"' in file '");
963            msg.extend_from_slice(&filename);
964            msg.push(b'\'');
965            let s = state.intern_str(&msg)?;
966            state.push(LuaValue::Str(s));
967            return Ok(1);
968        }
969        LookForFuncStatus::ErrLib(_) => {
970            // C: return checkload(L, 0, filename);  -- real error
971            return checkload(state, false, &filename);
972        }
973    }
974
975    // C: lua_pushstring(L, filename);  -- 2nd argument to module
976    let s = state.intern_str(&filename)?;
977    state.push(LuaValue::Str(s));
978    // C: return 2;
979    Ok(2)
980}
981
982/// Searcher that looks in `package.preload` for a pre-registered loader.
983///
984/// C: `static int searcher_preload(lua_State *L)`
985fn searcher_preload(state: &mut LuaState) -> Result<usize, LuaError> {
986    // C: const char *name = luaL_checkstring(L, 1);
987    let name = state.check_arg_string(1)?.to_vec();
988    // C: lua_getfield(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
989    state.get_field_registry(b"_PRELOAD");
990    // C: if (lua_getfield(L, -1, name) == LUA_TNIL) { ... }
991    let ty = state.get_field(-1, &name)?;
992    if ty == LuaType::Nil {
993        // C: lua_pushfstring(L, "no field package.preload['%s']", name);
994        let mut msg = b"no field package.preload['".to_vec();
995        msg.extend_from_slice(&name);
996        msg.push(b'\'');
997        msg.push(b']');
998        let s = state.intern_str(&msg)?;
999        state.push(LuaValue::Str(s));
1000        // C: return 1;
1001        return Ok(1);
1002    }
1003    // C: lua_pushliteral(L, ":preload:");
1004    let tag = state.intern_str(b":preload:")?;
1005    state.push(LuaValue::Str(tag));
1006    // C: return 2;
1007    Ok(2)
1008}
1009
1010// ── require implementation ────────────────────────────────────────────────────
1011
1012/// Iterate through `package.searchers` to find a loader for module `name`.
1013///
1014/// On success, leaves `(loader_function, loader_data)` at the top of the stack
1015/// (below the searchers table). On failure, raises a runtime error.
1016///
1017/// C: `static void findloader(lua_State *L, const char *name)`
1018///
1019/// TODO(port): The exact absolute stack indices used in C (index 3 for the
1020/// searchers table) depend on the caller (`ll_require`) having set up the
1021/// stack in a specific way. In Rust we use relative indices. The behaviour
1022/// should match C but the index arithmetic must be verified in Phase B.
1023fn findloader(state: &mut LuaState, name: &[u8]) -> Result<(), LuaError> {
1024    // C: if (lua_getfield(L, lua_upvalueindex(1), "searchers") != LUA_TTABLE)
1025    //        luaL_error(L, "'package.searchers' must be a table");
1026    let uv = state.upvalue_index(1);
1027    let ty = state.get_field(uv, b"searchers")?;
1028    if ty != LuaType::Table {
1029        return Err(LuaError::runtime(format_args!(
1030            "'package.searchers' must be a table"
1031        )));
1032    }
1033    // Searchers table is now at the top of the stack.
1034
1035    // C: luaL_buffinit(L, &msg);
1036    let mut msg_buf: Vec<u8> = Vec::new();
1037
1038    let mut i: i64 = 1;
1039    loop {
1040        // C: luaL_addstring(&msg, "\n\t");
1041        msg_buf.extend_from_slice(b"\n\t");
1042
1043        // C: if (lua_rawgeti(L, 3, i) == LUA_TNIL) { ... no more searchers }
1044        // PORT NOTE: In C the searchers table is at absolute index 3. In Rust
1045        // it is at -1 (relative to the top). TODO(port): verify this is correct
1046        // after accounting for whatever else the caller left on the stack.
1047        let item_ty = state.raw_geti(-1, i)?;
1048        if item_ty == LuaType::Nil {
1049            // C: lua_pop(L, 1);  -- remove nil
1050            state.pop_n(1);
1051            // C: luaL_buffsub(&msg, 2);
1052            let len = msg_buf.len();
1053            if len >= 2 {
1054                msg_buf.truncate(len - 2);
1055            }
1056            // C: luaL_pushresult(&msg); luaL_error(L, "module '%s' not found:%s", ...)
1057            // Build the error message as a Lua string then raise.
1058            let mut err = b"module '".to_vec();
1059            err.extend_from_slice(name);
1060            err.extend_from_slice(b"' not found:");
1061            err.extend_from_slice(&msg_buf);
1062            let err_s = state.intern_str(&err)?;
1063            return Err(LuaError::from_value(LuaValue::Str(err_s)));
1064        }
1065
1066        // C: lua_pushstring(L, name);
1067        let name_s = state.intern_str(name)?;
1068        state.push(LuaValue::Str(name_s));
1069
1070        // C: lua_call(L, 1, 2);
1071        state.call(1, 2)?;
1072
1073        // After call: two return values r1 (at -2) and r2 (at -1) on top.
1074        // C: if (lua_isfunction(L, -2)) return;
1075        if state.type_at(-2) == LuaType::Function {
1076            // Loader found; leave (r1=function, r2=data) on stack and return.
1077            return Ok(());
1078        }
1079
1080        // C: else if (lua_isstring(L, -2)) { lua_pop(L, 1); luaL_addvalue(&msg); }
1081        if state.type_at(-2) == LuaType::String {
1082            // r1 is an error-message string from the searcher.
1083            // C: lua_pop(L, 1)  -- remove r2 (the extra/nil return)
1084            state.pop_n(1);
1085            // C: luaL_addvalue(&msg)  -- append r1 (now at -1) to msg, pop it
1086            if let Some(bytes) = state.to_bytes(-1) {
1087                msg_buf.extend_from_slice(&bytes);
1088            }
1089            state.pop_n(1);
1090        } else {
1091            // C: lua_pop(L, 2);  -- remove both returns (no error message)
1092            state.pop_n(2);
1093            // C: luaL_buffsub(&msg, 2);  -- remove the "\n\t" prefix
1094            let len = msg_buf.len();
1095            if len >= 2 {
1096                msg_buf.truncate(len - 2);
1097            }
1098        }
1099
1100        i += 1;
1101    }
1102}
1103
1104/// `require(modname)` — load a module by name, using `package.loaded` as a
1105/// cache and `package.searchers` to find and load it if not already cached.
1106///
1107/// Returns the module value (and optionally the loader data) — 2 values.
1108///
1109/// C: `static int ll_require(lua_State *L)`
1110pub fn ll_require(state: &mut LuaState) -> Result<usize, LuaError> {
1111    // C: const char *name = luaL_checkstring(L, 1);
1112    let name = state.check_arg_string(1)?.to_vec();
1113
1114    // C: lua_settop(L, 1);  -- LOADED table will be at index 2
1115    // PORT NOTE: must use the public-API `set_top` (relative to the current
1116    // C-frame's `func`), not `LuaState::set_top` which is an inherent that
1117    // sets an absolute stack index and would truncate the entire stack.
1118    lua_vm::api::set_top(state, 1)?;
1119
1120    // C: lua_getfield(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
1121    state.get_field_registry(b"_LOADED")?;
1122
1123    // C: lua_getfield(L, 2, name);  -- LOADED[name]
1124    state.get_field(2, &name)?;
1125
1126    // C: if (lua_toboolean(L, -1)) return 1;  -- package is already loaded
1127    if state.to_boolean(-1) {
1128        return Ok(1);
1129    }
1130
1131    // C: lua_pop(L, 1);  -- remove 'getfield' result
1132    state.pop_n(1);
1133
1134    // C: findloader(L, name);
1135    // After this, the stack has: [name(1), LOADED(2), searchers(3), loader(-2), loaderdata(-1)]
1136    findloader(state, &name)?;
1137
1138    // C: lua_rotate(L, -2, 1);  -- function <-> loader data
1139    // Swaps loader and loaderdata: [..., loaderdata, loader]
1140    state.rotate(-2, 1);
1141
1142    // C: lua_pushvalue(L, 1);  -- name is 1st argument to module loader
1143    state.push_value(1);
1144
1145    // C: lua_pushvalue(L, -3);  -- loader data is 2nd argument
1146    // PORT NOTE: After the rotate, loaderdata is 3 from top (-3). In C this is
1147    // at absolute index 4 (but C uses the pre-rotate layout). TODO(port): verify.
1148    state.push_value(-3);
1149
1150    // C: lua_call(L, 2, 1);  -- run loader to load module
1151    state.call(2, 1)?;
1152
1153    // C: if (!lua_isnil(L, -1)) lua_setfield(L, 2, name);
1154    if state.type_at(-1) != LuaType::Nil {
1155        state.set_field(2, &name)?;
1156    } else {
1157        // C: else lua_pop(L, 1);
1158        state.pop_n(1);
1159    }
1160
1161    // C: if (lua_getfield(L, 2, name) == LUA_TNIL) { ... module set no value }
1162    let ty = state.get_field(2, &name)?;
1163    if ty == LuaType::Nil {
1164        // C: lua_pushboolean(L, 1); lua_copy(L, -1, -2); lua_setfield(L, 2, name);
1165        state.push(LuaValue::Bool(true));
1166        state.copy_value(-1, -2);
1167        state.set_field(2, &name)?;
1168    }
1169
1170    // C: lua_rotate(L, -2, 1);  -- loader data <-> module result
1171    state.rotate(-2, 1);
1172
1173    // C: return 2;  -- return module result and loader data
1174    Ok(2)
1175}
1176
1177// ── Package library setup ─────────────────────────────────────────────────────
1178
1179/// Create the `searchers` table and install the four built-in searchers, each
1180/// with the `package` table as upvalue #1.
1181///
1182/// C: `static void createsearcherstable(lua_State *L)`
1183fn createsearcherstable(state: &mut LuaState) -> Result<(), LuaError> {
1184    // C: static const lua_CFunction searchers[] = { searcher_preload,
1185    //        searcher_Lua, searcher_C, searcher_Croot, NULL };
1186    let searchers: &[fn(&mut LuaState) -> Result<usize, LuaError>] = &[
1187        searcher_preload,
1188        searcher_lua,
1189        searcher_c,
1190        searcher_croot,
1191    ];
1192
1193    // C: lua_createtable(L, sizeof(searchers)/sizeof(searchers[0]) - 1, 0);
1194    state.create_table(searchers.len() as i32, 0);
1195
1196    // C: for (i=0; searchers[i] != NULL; i++) { ... lua_pushcclosure(L, searchers[i], 1); ... }
1197    for (i, &f) in searchers.iter().enumerate() {
1198        // C: lua_pushvalue(L, -2);  -- set 'package' as upvalue for all searchers
1199        state.push_value(-2);
1200        // C: lua_pushcclosure(L, searchers[i], 1);
1201        // TODO(port): push_c_closure takes the function and n upvalues from the
1202        //             stack. The package table upvalue must be correctly associated
1203        //             with each searcher closure so that findfile can access it
1204        //             via lua_upvalueindex(1). Verify in Phase B.
1205        state.push_c_closure(f, 1)?;
1206        // C: lua_rawseti(L, -2, i+1);
1207        state.raw_seti(-2, (i + 1) as i64)?;
1208    }
1209    // C: lua_setfield(L, -2, "searchers");
1210    state.set_field(-2, b"searchers")?;
1211    Ok(())
1212}
1213
1214/// Create the `_CLIBS` registry table with a `__gc` finalizer that closes all
1215/// loaded C libraries when the Lua state is closed.
1216///
1217/// C: `static void createclibstable(lua_State *L)`
1218fn createclibstable(state: &mut LuaState) -> Result<(), LuaError> {
1219    // C: luaL_getsubtable(L, LUA_REGISTRYINDEX, CLIBS);
1220    state.get_subtable_registry(CLIBS)?;
1221    // C: lua_createtable(L, 0, 1);  -- metatable for CLIBS
1222    state.create_table(0, 1);
1223    // C: lua_pushcfunction(L, gctm);
1224    // TODO(phase-b): LuaClosure::LightC currently typed fn() -> i32 in lua-types; use push_c_function until widened.
1225    state.push_c_function(gctm)?;
1226    // C: lua_setfield(L, -2, "__gc");
1227    state.set_field(-2, b"__gc")?;
1228    // C: lua_setmetatable(L, -2);
1229    state.set_metatable(-2)?;
1230    Ok(())
1231}
1232
1233/// Open the `package` library and return the `package` table.
1234///
1235/// C: `LUAMOD_API int luaopen_package(lua_State *L)`
1236pub fn luaopen_package(state: &mut LuaState) -> Result<usize, LuaError> {
1237    // C: createclibstable(L);
1238    createclibstable(state)?;
1239
1240    // C: luaL_newlib(L, pk_funcs);  -- create 'package' table
1241    // PORT NOTE: The C pk_funcs table also contains placeholder entries for
1242    // "preload", "cpath", "path", "searchers", "loaded" (all NULL). In Rust
1243    // those fields are set explicitly below; only the real functions are here.
1244    state.new_lib(&[
1245        (b"loadlib" as &[u8], ll_loadlib as fn(&mut LuaState) -> Result<usize, LuaError>),
1246        (b"searchpath", ll_searchpath as fn(&mut LuaState) -> Result<usize, LuaError>),
1247    ])?;
1248
1249    // C: createsearcherstable(L);
1250    createsearcherstable(state)?;
1251
1252    // C: setpath(L, "path", LUA_PATH_VAR, LUA_PATH_DEFAULT);
1253    setpath(state, b"path", LUA_PATH_VAR, LUA_PATH_DEFAULT)?;
1254
1255    // C: setpath(L, "cpath", LUA_CPATH_VAR, LUA_CPATH_DEFAULT);
1256    setpath(state, b"cpath", LUA_CPATH_VAR, LUA_CPATH_DEFAULT)?;
1257
1258    // C: lua_pushliteral(L, LUA_DIRSEP "\n" LUA_PATH_SEP "\n" LUA_PATH_MARK "\n"
1259    //                       LUA_EXEC_DIR "\n" LUA_IGMARK "\n");
1260    // The config string encodes platform separator characters, one per line.
1261    let mut config: Vec<u8> = Vec::new();
1262    config.push(LUA_DIRSEP);
1263    config.push(b'\n');
1264    config.push(LUA_PATH_SEP);
1265    config.push(b'\n');
1266    config.push(LUA_PATH_MARK);
1267    config.push(b'\n');
1268    config.push(b'!');   // LUA_EXEC_DIR
1269    config.push(b'\n');
1270    config.push(LUA_IGMARK);
1271    config.push(b'\n');
1272    let config_s = state.intern_str(&config)?;
1273    state.push(LuaValue::Str(config_s));
1274
1275    // C: lua_setfield(L, -2, "config");
1276    state.set_field(-2, b"config")?;
1277
1278    // C: luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_LOADED_TABLE);
1279    state.get_subtable_registry(b"_LOADED")?;
1280    // C: lua_setfield(L, -2, "loaded");
1281    state.set_field(-2, b"loaded")?;
1282
1283    // C: luaL_getsubtable(L, LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE);
1284    state.get_subtable_registry(b"_PRELOAD")?;
1285    // C: lua_setfield(L, -2, "preload");
1286    state.set_field(-2, b"preload")?;
1287
1288    // C: lua_pushglobaltable(L);
1289    state.push_globals();
1290    // C: lua_pushvalue(L, -2);  -- set 'package' as upvalue for next lib
1291    state.push_value(-2);
1292    // C: luaL_setfuncs(L, ll_funcs, 1);  -- open lib into global table
1293    state.set_funcs_with_upvalues(
1294        &[(b"require" as &[u8], ll_require as fn(&mut LuaState) -> Result<usize, LuaError>)],
1295        1,
1296    )?;
1297    // C: lua_pop(L, 1);  -- pop global table
1298    state.pop_n(1);
1299
1300    // C: return 1;  -- return 'package' table
1301    Ok(1)
1302}
1303
1304// ──────────────────────────────────────────────────────────────────────────────
1305// PORT STATUS
1306//   source:        src/loadlib.c  (758 lines, 25 functions)
1307//   target_crate:  lua-stdlib
1308//   confidence:    medium
1309//   todos:         8
1310//   port_notes:    7
1311//   unsafe_blocks: 0   (must be 0 outside explicit unsafe-budget crates)
1312//   notes:         lsys_load/lsys_sym/lsys_unloadlib now dispatch through
1313//                  dynlib_*_hook on GlobalState (Phase D-3.5); lua-cli
1314//                  installs a libloading-backed backend. With no hook
1315//                  installed, LIB_FAIL is "absent" (matches the C fallback
1316//                  stub); with a hook installed it is "open". Stock Lua C
1317//                  ABI symbols resolve but fail with "init" + a clear
1318//                  unsupported-ABI message (DynamicSymbol::LuaCAbi case);
1319//                  full C-ABI compatibility is a separate project. readable()
1320//                  and searcher_lua are wired through file_loader_hook on
1321//                  GlobalState. Stack-index arithmetic in findloader /
1322//                  ll_require should be verified in Phase B. LUA_PATH_DEFAULT
1323//                  / LUA_CPATH_DEFAULT are hardcoded and must be replaced
1324//                  with platform configuration constants.
1325// ──────────────────────────────────────────────────────────────────────────────