Skip to main content

lua_rs_lfs/
lib.rs

1//! Rust-native port of the LuaFileSystem (lfs) module — Phase G-1.
2//!
3//! Provides the lfs functions LuaRocks actually uses:
4//!
5//! | function                         | std::fs / std::env equivalent      |
6//! |----------------------------------|------------------------------------|
7//! | `lfs.attributes(path [, req])`   | `std::fs::metadata(path)`          |
8//! | `lfs.dir(path)`                  | `std::fs::read_dir(path)`          |
9//! | `lfs.mkdir(path)`                | `std::fs::create_dir(path)`        |
10//! | `lfs.rmdir(path)`                | `std::fs::remove_dir(path)`        |
11//! | `lfs.chdir(path)`                | `std::env::set_current_dir(path)`  |
12//! | `lfs.currentdir()`               | `std::env::current_dir()`          |
13//! | `lfs.touch(path [,a [,m]])`      | `filetime::set_file_times`         |
14//! | `lfs.link(old, new [, sym])`     | `std::fs::hard_link` / `symlink`   |
15//! | `lfs.lock_dir(path)`             | atomic `lockfile.lfs` creation     |
16//!
17//! Out of scope (LuaRocks does not exercise these): `lfs.lock`, `lfs.unlock`,
18//! `lfs.symlinkattributes`, `lfs.setmode`.
19//!
20//! Registration: the entry point [`luaopen_lfs`] builds and returns the lfs
21//! table. `lua-cli` installs that function in `package.preload.lfs` after
22//! `open_libs` and before user code runs, so `require('lfs')` resolves via
23//! the preload searcher and the rest of the lfs API ends up where stock lfs
24//! users expect to find it.
25
26use std::cell::RefCell;
27use std::collections::HashMap;
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use lua_types::error::LuaError;
33use lua_types::value::LuaValue;
34use lua_vm::state::LuaState;
35
36/// Each `lfs.dir` call allocates one zero-sized userdata to act as the
37/// iterator handle; the real iterator state lives in this side-table keyed by
38/// the userdata's identity. The same pattern is used by `lua-stdlib`'s
39/// `io_lib` because Lua's userdata payload is `Box<[u8]>` and cannot hold
40/// arbitrary Rust types directly.
41///
42/// Entries are inserted by `lfs_dir` and cleaned up either when the iterator
43/// is exhausted or when the userdata's metatable `__gc` handler runs.
44struct DirIterState {
45    iter: Option<std::fs::ReadDir>,
46}
47
48thread_local! {
49    static DIR_ITER_REGISTRY: RefCell<HashMap<usize, DirIterState>> =
50        RefCell::new(HashMap::new());
51}
52
53/// Convert a byte-string path argument (as Lua sees it) to a `PathBuf`. On
54/// Unix this is the zero-copy path that preserves non-UTF-8 byte sequences;
55/// on non-Unix targets we fall back to UTF-8 and surface a runtime error if
56/// the bytes are not valid UTF-8.
57fn path_from_bytes(bytes: &[u8]) -> Result<PathBuf, LuaError> {
58    #[cfg(unix)]
59    {
60        use std::os::unix::ffi::OsStrExt;
61        Ok(PathBuf::from(std::ffi::OsStr::from_bytes(bytes)))
62    }
63    #[cfg(not(unix))]
64    {
65        let s = std::str::from_utf8(bytes).map_err(|_| {
66            LuaError::runtime(format_args!("path is not valid UTF-8"))
67        })?;
68        Ok(PathBuf::from(s))
69    }
70}
71
72/// Returns the byte representation of a `Path` suitable for handing back to
73/// Lua. Unix preserves raw bytes; other platforms lossy-convert through
74/// UTF-8.
75fn path_to_bytes(p: &Path) -> Vec<u8> {
76    #[cfg(unix)]
77    {
78        use std::os::unix::ffi::OsStrExt;
79        p.as_os_str().as_bytes().to_vec()
80    }
81    #[cfg(not(unix))]
82    {
83        p.to_string_lossy().into_owned().into_bytes()
84    }
85}
86
87/// Push a failure result of the form `(nil, errmsg)` and return `2`, matching
88/// the convention every lfs function uses when an underlying syscall fails.
89fn push_fail(state: &mut LuaState, msg: &str) -> Result<usize, LuaError> {
90    state.push(LuaValue::Nil);
91    state.push_string(msg.as_bytes())?;
92    Ok(2)
93}
94
95// ─── lfs.currentdir ────────────────────────────────────────────────────────
96
97/// Push the absolute path of the current working directory, or
98/// `(nil, errmsg)` on failure. C-lfs: `lfs_currentdir`.
99fn lfs_currentdir(state: &mut LuaState) -> Result<usize, LuaError> {
100    match std::env::current_dir() {
101        Ok(p) => {
102            let bytes = path_to_bytes(&p);
103            state.push_string(&bytes)?;
104            Ok(1)
105        }
106        Err(e) => push_fail(state, &e.to_string()),
107    }
108}
109
110// ─── lfs.chdir ─────────────────────────────────────────────────────────────
111
112/// Change the current working directory. Returns `true` on success, or
113/// `(nil, errmsg)` on failure. C-lfs: `lfs_chdir`.
114fn lfs_chdir(state: &mut LuaState) -> Result<usize, LuaError> {
115    let path = path_from_bytes(&state.check_arg_string(1)?)?;
116    match std::env::set_current_dir(&path) {
117        Ok(()) => {
118            state.push(LuaValue::Bool(true));
119            Ok(1)
120        }
121        Err(e) => push_fail(state, &format!("Unable to change working directory to '{}': {}", path.display(), e)),
122    }
123}
124
125// ─── lfs.mkdir ─────────────────────────────────────────────────────────────
126
127/// Create a directory at `path`. Returns `true` on success, or
128/// `(nil, errmsg)` on failure. C-lfs: `make_dir`.
129fn lfs_mkdir(state: &mut LuaState) -> Result<usize, LuaError> {
130    let path = path_from_bytes(&state.check_arg_string(1)?)?;
131    match std::fs::create_dir(&path) {
132        Ok(()) => {
133            state.push(LuaValue::Bool(true));
134            Ok(1)
135        }
136        Err(e) => push_fail(state, &e.to_string()),
137    }
138}
139
140// ─── lfs.rmdir ─────────────────────────────────────────────────────────────
141
142/// Remove an empty directory at `path`. Returns `true` on success, or
143/// `(nil, errmsg)` on failure. C-lfs: `remove_dir`.
144fn lfs_rmdir(state: &mut LuaState) -> Result<usize, LuaError> {
145    let path = path_from_bytes(&state.check_arg_string(1)?)?;
146    match std::fs::remove_dir(&path) {
147        Ok(()) => {
148            state.push(LuaValue::Bool(true));
149            Ok(1)
150        }
151        Err(e) => push_fail(state, &e.to_string()),
152    }
153}
154
155// ─── lfs.link ──────────────────────────────────────────────────────────────
156
157/// Create either a hard link or a symbolic link from `new` to `old` depending
158/// on the optional third argument. Returns `true` on success or
159/// `(nil, errmsg)` on failure. C-lfs: `make_link`.
160///
161/// Argument order matches stock lfs: `lfs.link(old, new [, symlink])` —
162/// `old` is the existing target, `new` is the path of the link being created.
163fn lfs_link(state: &mut LuaState) -> Result<usize, LuaError> {
164    let old_path = path_from_bytes(&state.check_arg_string(1)?)?;
165    let new_path = path_from_bytes(&state.check_arg_string(2)?)?;
166    let symlink = lua_vm::api::to_boolean(state, 3);
167
168    let result = if symlink {
169        #[cfg(unix)]
170        {
171            std::os::unix::fs::symlink(&old_path, &new_path)
172        }
173        #[cfg(windows)]
174        {
175            if old_path.is_dir() {
176                std::os::windows::fs::symlink_dir(&old_path, &new_path)
177            } else {
178                std::os::windows::fs::symlink_file(&old_path, &new_path)
179            }
180        }
181        #[cfg(not(any(unix, windows)))]
182        {
183            Err(std::io::Error::new(std::io::ErrorKind::Other, "symlinks not supported on this platform"))
184        }
185    } else {
186        std::fs::hard_link(&old_path, &new_path)
187    };
188
189    match result {
190        Ok(()) => {
191            state.push(LuaValue::Bool(true));
192            Ok(1)
193        }
194        Err(e) => push_fail(state, &e.to_string()),
195    }
196}
197
198// ─── lfs.touch ─────────────────────────────────────────────────────────────
199
200/// Update the access and modification times of `path`. Both times default to
201/// the current wall-clock time if absent; if only the access time is given
202/// it is used for the modification time as well, matching stock lfs.
203/// C-lfs: `file_utime`.
204fn lfs_touch(state: &mut LuaState) -> Result<usize, LuaError> {
205    let path = path_from_bytes(&state.check_arg_string(1)?)?;
206
207    let now_secs: f64 = SystemTime::now()
208        .duration_since(UNIX_EPOCH)
209        .map(|d| d.as_secs_f64())
210        .unwrap_or(0.0);
211
212    let arg2_type = lua_vm::api::lua_type_at(state, 2);
213    let atime: f64 = match arg2_type {
214        lua_types::LuaType::None | lua_types::LuaType::Nil => now_secs,
215        _ => state.check_number(2)?,
216    };
217    let arg3_type = lua_vm::api::lua_type_at(state, 3);
218    let mtime: f64 = match arg3_type {
219        lua_types::LuaType::None | lua_types::LuaType::Nil => atime,
220        _ => state.check_number(3)?,
221    };
222
223    let atime_ft = filetime::FileTime::from_unix_time(
224        atime.trunc() as i64,
225        ((atime.fract().abs()) * 1_000_000_000.0) as u32,
226    );
227    let mtime_ft = filetime::FileTime::from_unix_time(
228        mtime.trunc() as i64,
229        ((mtime.fract().abs()) * 1_000_000_000.0) as u32,
230    );
231
232    match filetime::set_file_times(&path, atime_ft, mtime_ft) {
233        Ok(()) => {
234            state.push(LuaValue::Bool(true));
235            Ok(1)
236        }
237        Err(e) => push_fail(state, &e.to_string()),
238    }
239}
240
241// ─── lfs.lock_dir ─────────────────────────────────────────────────────────
242
243fn lfs_lock_free(state: &mut LuaState) -> Result<usize, LuaError> {
244    lua_vm::api::push_value(state, upvalue_index(1));
245    let lockfile = match state.pop() {
246        LuaValue::Str(s) => path_from_bytes(s.as_bytes())?,
247        _ => {
248            return Err(LuaError::runtime(format_args!(
249                "lfs.lock_dir: missing lockfile upvalue"
250            )));
251        }
252    };
253
254    match std::fs::remove_file(&lockfile) {
255        Ok(()) => {
256            state.push(LuaValue::Bool(true));
257            Ok(1)
258        }
259        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
260            state.push(LuaValue::Bool(true));
261            Ok(1)
262        }
263        Err(e) => push_fail(state, &e.to_string()),
264    }
265}
266
267/// `lfs.lock_dir(path)` — acquire a coarse directory lock.
268///
269/// LuaRocks uses LuaFileSystem's `lock_dir` by creating `path/lockfile.lfs`
270/// and later calling `lock:free()`.  We implement just that stock contract
271/// with `create_new(true)` so acquisition is atomic on the host filesystem.
272fn lfs_lock_dir(state: &mut LuaState) -> Result<usize, LuaError> {
273    let dir = path_from_bytes(&state.check_arg_string(1)?)?;
274    if let Err(e) = std::fs::create_dir_all(&dir) {
275        return push_fail(state, &e.to_string());
276    }
277
278    let lockfile = dir.join("lockfile.lfs");
279    match std::fs::OpenOptions::new()
280        .write(true)
281        .create_new(true)
282        .open(&lockfile)
283    {
284        Ok(mut file) => {
285            let _ = file.write_all(b"lock");
286        }
287        Err(e) => return push_fail(state, &e.to_string()),
288    }
289
290    state.create_table(0, 1)?;
291    let lockfile_bytes = path_to_bytes(&lockfile);
292    state.push_string(&lockfile_bytes)?;
293    lua_vm::api::push_cclosure(state, lfs_lock_free, 1)?;
294    lua_vm::api::set_field(state, -2, b"free")?;
295    Ok(1)
296}
297
298// ─── lfs.attributes ────────────────────────────────────────────────────────
299
300/// Translate `std::fs::FileType` to one of stock lfs's mode strings. The
301/// returned value is the byte form expected by the existing lfs ecosystem
302/// (`"file"`, `"directory"`, `"link"`, …).
303fn mode_string(ft: std::fs::FileType) -> &'static [u8] {
304    if ft.is_file() {
305        b"file"
306    } else if ft.is_dir() {
307        b"directory"
308    } else if ft.is_symlink() {
309        b"link"
310    } else {
311        #[cfg(unix)]
312        {
313            use std::os::unix::fs::FileTypeExt;
314            if ft.is_socket() {
315                return b"socket";
316            }
317            if ft.is_fifo() {
318                return b"named pipe";
319            }
320            if ft.is_char_device() {
321                return b"char device";
322            }
323            if ft.is_block_device() {
324                return b"block device";
325            }
326        }
327        b"other"
328    }
329}
330
331/// Convert a `SystemTime` to seconds-since-epoch as an integer, matching
332/// stock lfs's `time_t`-shaped fields. Negative durations (pre-epoch) become
333/// negative integers, exactly like C's `time_t`.
334fn system_time_to_secs(t: std::io::Result<SystemTime>) -> i64 {
335    match t {
336        Ok(st) => match st.duration_since(UNIX_EPOCH) {
337            Ok(d) => d.as_secs() as i64,
338            Err(e) => -(e.duration().as_secs() as i64),
339        },
340        Err(_) => 0,
341    }
342}
343
344/// Catalog of `attributes` field names → emitter. The emitter pushes the
345/// field's value onto the stack. Centralising the table avoids duplication
346/// between the full-table mode and the single-field request mode.
347fn push_attr_field(
348    state: &mut LuaState,
349    field: &[u8],
350    md: &std::fs::Metadata,
351) -> Result<bool, LuaError> {
352    match field {
353        b"mode" => {
354            state.push_string(mode_string(md.file_type()))?;
355        }
356        b"size" => {
357            state.push(LuaValue::Int(md.len() as i64));
358        }
359        b"modification" => {
360            state.push(LuaValue::Int(system_time_to_secs(md.modified())));
361        }
362        b"access" => {
363            state.push(LuaValue::Int(system_time_to_secs(md.accessed())));
364        }
365        b"change" => {
366            #[cfg(unix)]
367            {
368                use std::os::unix::fs::MetadataExt;
369                state.push(LuaValue::Int(md.ctime()));
370            }
371            #[cfg(not(unix))]
372            {
373                state.push(LuaValue::Int(system_time_to_secs(md.modified())));
374            }
375        }
376        b"permissions" => {
377            #[cfg(unix)]
378            {
379                use std::os::unix::fs::PermissionsExt;
380                let m = md.permissions().mode() & 0o777;
381                let mut buf = [b'-'; 9];
382                let bits = [
383                    (0o400, 0, b'r'), (0o200, 1, b'w'), (0o100, 2, b'x'),
384                    (0o040, 3, b'r'), (0o020, 4, b'w'), (0o010, 5, b'x'),
385                    (0o004, 6, b'r'), (0o002, 7, b'w'), (0o001, 8, b'x'),
386                ];
387                for (mask, idx, ch) in bits {
388                    if m & mask != 0 {
389                        buf[idx] = ch;
390                    }
391                }
392                state.push_string(&buf)?;
393            }
394            #[cfg(not(unix))]
395            {
396                let s = if md.permissions().readonly() { b"r--r--r--" } else { b"rw-rw-rw-" };
397                state.push_string(s)?;
398            }
399        }
400        b"nlink" => {
401            #[cfg(unix)]
402            {
403                use std::os::unix::fs::MetadataExt;
404                state.push(LuaValue::Int(md.nlink() as i64));
405            }
406            #[cfg(not(unix))]
407            {
408                state.push(LuaValue::Int(1));
409            }
410        }
411        b"uid" => {
412            #[cfg(unix)]
413            {
414                use std::os::unix::fs::MetadataExt;
415                state.push(LuaValue::Int(md.uid() as i64));
416            }
417            #[cfg(not(unix))]
418            {
419                state.push(LuaValue::Int(0));
420            }
421        }
422        b"gid" => {
423            #[cfg(unix)]
424            {
425                use std::os::unix::fs::MetadataExt;
426                state.push(LuaValue::Int(md.gid() as i64));
427            }
428            #[cfg(not(unix))]
429            {
430                state.push(LuaValue::Int(0));
431            }
432        }
433        b"dev" => {
434            #[cfg(unix)]
435            {
436                use std::os::unix::fs::MetadataExt;
437                state.push(LuaValue::Int(md.dev() as i64));
438            }
439            #[cfg(not(unix))]
440            {
441                state.push(LuaValue::Int(0));
442            }
443        }
444        b"rdev" => {
445            #[cfg(unix)]
446            {
447                use std::os::unix::fs::MetadataExt;
448                state.push(LuaValue::Int(md.rdev() as i64));
449            }
450            #[cfg(not(unix))]
451            {
452                state.push(LuaValue::Int(0));
453            }
454        }
455        b"ino" => {
456            #[cfg(unix)]
457            {
458                use std::os::unix::fs::MetadataExt;
459                state.push(LuaValue::Int(md.ino() as i64));
460            }
461            #[cfg(not(unix))]
462            {
463                state.push(LuaValue::Int(0));
464            }
465        }
466        b"blocks" => {
467            #[cfg(unix)]
468            {
469                use std::os::unix::fs::MetadataExt;
470                state.push(LuaValue::Int(md.blocks() as i64));
471            }
472            #[cfg(not(unix))]
473            {
474                state.push(LuaValue::Int(0));
475            }
476        }
477        b"blksize" => {
478            #[cfg(unix)]
479            {
480                use std::os::unix::fs::MetadataExt;
481                state.push(LuaValue::Int(md.blksize() as i64));
482            }
483            #[cfg(not(unix))]
484            {
485                state.push(LuaValue::Int(0));
486            }
487        }
488        _ => return Ok(false),
489    }
490    Ok(true)
491}
492
493/// `lfs.attributes(path [, request])` — `stat`-like introspection.
494///
495/// Three calling shapes match stock lfs:
496///   * one-arg: return a fresh table whose keys are every attribute name.
497///   * two-arg with a string request: return just that field.
498///   * two-arg with a table: populate the given table in place and return it.
499fn lfs_attributes(state: &mut LuaState) -> Result<usize, LuaError> {
500    let path = path_from_bytes(&state.check_arg_string(1)?)?;
501    let md = match std::fs::metadata(&path) {
502        Ok(m) => m,
503        Err(e) => {
504            return push_fail(state, &format!("cannot obtain information from file '{}': {}", path.display(), e));
505        }
506    };
507
508    let arg2_type = lua_vm::api::lua_type_at(state, 2);
509    match arg2_type {
510        lua_types::LuaType::String => {
511            let req = state.check_arg_string(2)?;
512            if !push_attr_field(state, &req, &md)? {
513                return Err(LuaError::runtime(format_args!(
514                    "invalid attribute name '{}'",
515                    String::from_utf8_lossy(&req)
516                )));
517            }
518            Ok(1)
519        }
520        lua_types::LuaType::Table => {
521            lua_vm::api::push_value(state, 2);
522            populate_attr_table(state, &md)?;
523            Ok(1)
524        }
525        _ => {
526            state.create_table(0, 14)?;
527            populate_attr_table(state, &md)?;
528            Ok(1)
529        }
530    }
531}
532
533/// Fill the table on top of the stack with every attribute field. Used by the
534/// no-request and table-request branches of [`lfs_attributes`].
535fn populate_attr_table(
536    state: &mut LuaState,
537    md: &std::fs::Metadata,
538) -> Result<(), LuaError> {
539    const FIELDS: &[&[u8]] = &[
540        b"mode", b"size", b"modification", b"access", b"change",
541        b"permissions", b"nlink", b"uid", b"gid", b"dev", b"rdev",
542        b"ino", b"blocks", b"blksize",
543    ];
544    for field in FIELDS {
545        if !push_attr_field(state, field, md)? {
546            continue;
547        }
548        lua_vm::api::set_field(state, -2, field)?;
549    }
550    Ok(())
551}
552
553// ─── lfs.dir ───────────────────────────────────────────────────────────────
554
555/// Iterator step function — installed as a C closure with one upvalue
556/// (the userdata that owns the `ReadDir`). Each call returns the next
557/// directory entry's name as a string, or `nil` when exhausted.
558fn lfs_dir_next(state: &mut LuaState) -> Result<usize, LuaError> {
559    let ud_idx = upvalue_index(1);
560    lua_vm::api::push_value(state, ud_idx);
561    let v = state.pop();
562    let id = match v {
563        LuaValue::UserData(u) => u.identity(),
564        _ => {
565            return Err(LuaError::runtime(format_args!(
566                "lfs.dir iterator: missing handle upvalue"
567            )));
568        }
569    };
570
571    let next = DIR_ITER_REGISTRY.with(|reg| {
572        let mut map = reg.borrow_mut();
573        let entry = match map.get_mut(&id) {
574            Some(e) => e,
575            None => return None,
576        };
577        let iter = match entry.iter.as_mut() {
578            Some(i) => i,
579            None => return None,
580        };
581        loop {
582            match iter.next() {
583                Some(Ok(de)) => {
584                    let name = de.file_name();
585                    let bytes = {
586                        #[cfg(unix)]
587                        {
588                            use std::os::unix::ffi::OsStrExt;
589                            name.as_bytes().to_vec()
590                        }
591                        #[cfg(not(unix))]
592                        {
593                            name.to_string_lossy().into_owned().into_bytes()
594                        }
595                    };
596                    return Some(Some(bytes));
597                }
598                Some(Err(_)) => continue,
599                None => {
600                    entry.iter = None;
601                    return Some(None);
602                }
603            }
604        }
605    });
606
607    match next {
608        Some(Some(bytes)) => {
609            state.push_string(&bytes)?;
610            Ok(1)
611        }
612        _ => {
613            state.push(LuaValue::Nil);
614            Ok(1)
615        }
616    }
617}
618
619/// `lfs.dir(path)` — open the directory and return `(iterator, handle)`.
620///
621/// The iterator is a closure over a zero-byte userdata; the userdata's
622/// identity (a `usize` from `GcRef::identity`) is the side-table key holding
623/// the live `std::fs::ReadDir`. When the iterator function is later called,
624/// it re-reads the userdata via its upvalue and looks the iterator up.
625///
626/// Returning the handle as the second value matches stock lfs's signature —
627/// callers commonly write `for name in lfs.dir(path) do ... end` and never
628/// observe it, but a few rare scripts do.
629fn lfs_dir(state: &mut LuaState) -> Result<usize, LuaError> {
630    let path = path_from_bytes(&state.check_arg_string(1)?)?;
631    let iter = std::fs::read_dir(&path).map_err(|e| {
632        LuaError::runtime(format_args!(
633            "cannot open directory '{}': {}",
634            path.display(),
635            e
636        ))
637    })?;
638
639    let ud = state.new_userdata_typed(b"lfs.dir.handle", 0, 0)?;
640    let id = ud.identity();
641    DIR_ITER_REGISTRY.with(|reg| {
642        reg.borrow_mut().insert(id, DirIterState { iter: Some(iter) });
643    });
644
645    lua_vm::api::push_cclosure(state, lfs_dir_next, 1)?;
646    Ok(1)
647}
648
649// ─── Module entry point ────────────────────────────────────────────────────
650
651/// `lua_upvalueindex(i)` macro from `lua.h`. The duplicate in `lua-stdlib`'s
652/// state stub is module-private, so we keep an in-crate copy here. Phase F
653/// or G can de-duplicate by exposing one canonical constant from `lua-vm`.
654fn upvalue_index(i: i32) -> i32 {
655    -1_001_000 - i
656}
657
658const LFS_FUNCS: &[(&[u8], fn(&mut LuaState) -> Result<usize, LuaError>)] = &[
659    (b"attributes", lfs_attributes),
660    (b"chdir",      lfs_chdir),
661    (b"currentdir", lfs_currentdir),
662    (b"dir",        lfs_dir),
663    (b"link",       lfs_link),
664    (b"lock_dir",   lfs_lock_dir),
665    (b"mkdir",      lfs_mkdir),
666    (b"rmdir",      lfs_rmdir),
667    (b"touch",      lfs_touch),
668];
669
670/// Module entry point. Mirrors the stock lfs C signature
671/// `int luaopen_lfs(lua_State *L)` but at the Rust-native ABI used inside
672/// this workspace: builds the `lfs` table, populates it with the 8 functions
673/// above, and returns `1` to signal "one return value on the stack".
674///
675/// Installed in `package.preload.lfs` by `lua-cli`'s `main.rs`, so
676/// `require('lfs')` resolves the preload searcher and pushes this table.
677pub fn luaopen_lfs(state: &mut LuaState) -> Result<usize, LuaError> {
678    state.create_table(0, LFS_FUNCS.len() as i32)?;
679    for (name, func) in LFS_FUNCS {
680        lua_vm::api::push_cclosure(state, *func, 0)?;
681        lua_vm::api::set_field(state, -2, name)?;
682    }
683    Ok(1)
684}
685
686// ──────────────────────────────────────────────────────────────────────────
687// PORT STATUS
688//   source:        external — LuaFileSystem (lfs) by Roberto Ierusalimschy
689//                  et al., subset corresponding to LuaRocks' usage; not a
690//                  port of any file inside reference/lua-5.4.7/.
691//   target_crate:  lua-rs-lfs
692//   confidence:    high
693//   todos:         0
694//   port_notes:    0
695//   unsafe_blocks: 0
696//   notes:         Rust-native module exposed via package.preload.lfs by
697//                  lua-cli. Statically linked, no FFI, no unsafe. Implements
698//                  the lfs functions LuaRocks needs: attributes, dir, mkdir,
699//                  rmdir, chdir, currentdir, touch, link, lock_dir. Out of
700//                  scope: lock/unlock/symlinkattributes/setmode.
701// ──────────────────────────────────────────────────────────────────────────