use std::io::{self, Read, Seek, SeekFrom, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use omnilua::{HostHooks, Lua, LuaFileHandle, LuaVersion};
struct TestFsFile {
inner: std::fs::File,
pushback: Option<u8>,
err: Option<(i32, String)>,
}
impl LuaFileHandle for TestFsFile {
fn read_byte(&mut self) -> i32 {
if let Some(b) = self.pushback.take() {
return b as i32;
}
let mut buf = [0u8; 1];
match self.inner.read(&mut buf) {
Ok(1) => buf[0] as i32,
Ok(_) => -1,
Err(e) => {
self.err = Some((e.raw_os_error().unwrap_or(0), e.to_string()));
-1
}
}
}
fn unread_byte(&mut self, byte: i32) {
if byte >= 0 {
self.pushback = Some(byte as u8);
}
}
fn write_bytes(&mut self, data: &[u8]) -> io::Result<usize> {
self.inner.write(data)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
self.pushback = None;
self.inner.seek(pos)
}
fn tell(&mut self) -> io::Result<u64> {
self.inner.seek(SeekFrom::Current(0))
}
fn clear_error(&mut self) {
self.err = None;
}
fn has_error(&self) -> bool {
self.err.is_some()
}
fn last_error_info(&self) -> Option<(i32, String)> {
self.err.clone()
}
}
thread_local! {
static SCRATCH_PATH: std::cell::RefCell<Option<std::path::PathBuf>> =
const { std::cell::RefCell::new(None) };
}
fn test_file_open_hook(_filename: &[u8], mode: &[u8]) -> io::Result<Box<dyn LuaFileHandle>> {
let path = SCRATCH_PATH
.with(|p| p.borrow().clone())
.expect("scratch path must be set before io.open in a test");
let read = mode.first() == Some(&b'r');
let file = if read {
std::fs::File::open(&path)
} else {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
}?;
Ok(Box::new(TestFsFile {
inner: file,
pushback: None,
err: None,
}))
}
static SCRATCH_COUNTER: AtomicU64 = AtomicU64::new(0);
fn make_scratch(contents: &[u8]) -> std::path::PathBuf {
let n = SCRATCH_COUNTER.fetch_add(1, Ordering::Relaxed);
let mut path = std::env::temp_dir();
path.push(format!(
"omnilua_io_strengthen_{}_{}",
std::process::id(),
n
));
std::fs::write(&path, contents).expect("write scratch file");
SCRATCH_PATH.with(|p| *p.borrow_mut() = Some(path.clone()));
path
}
fn lua_with_fs(version: LuaVersion) -> Lua {
let hooks = HostHooks::new().file_open(test_file_open_hook);
Lua::with_hooks_versioned(hooks, version).expect("init lua with fs hook")
}
fn eval_str(version: LuaVersion, scratch: &[u8], code: &str) -> String {
let _path = make_scratch(scratch);
let lua = lua_with_fs(version);
let result: String = lua
.load(code)
.set_name(b"=io_test")
.eval()
.unwrap_or_else(|e| panic!("eval of `{code}` failed under {version:?}: {e:?}"));
SCRATCH_PATH.with(|p| {
if let Some(path) = p.borrow_mut().take() {
let _ = std::fs::remove_file(path);
}
});
result
}
const ALL: [LuaVersion; 5] = [
LuaVersion::V51,
LuaVersion::V52,
LuaVersion::V53,
LuaVersion::V54,
LuaVersion::V55,
];
#[test]
fn read_unknown_bare_format_wording_crossversion() {
let code = "local f = io.open('x','r'); \
local ok, err = pcall(function() return f:read('z') end); \
f:close(); \
return tostring(err)";
for v in ALL {
let got = eval_str(v, b"data\n", code);
let expected_tail = match v {
LuaVersion::V51 | LuaVersion::V52 => "(invalid option)",
_ => "(invalid format)",
};
assert!(
got.contains("bad argument #1 to 'read'") && got.ends_with(expected_tail),
"{v:?}: read('z') error was `{got}`, expected to end with \
`bad argument #1 to 'read' {expected_tail}`"
);
}
}
#[test]
fn read_unknown_starred_format_is_invalid_format_everywhere() {
let code = "local f = io.open('x','r'); \
local ok, err = pcall(function() return f:read('*z') end); \
f:close(); \
return tostring(err)";
for v in ALL {
let got = eval_str(v, b"data\n", code);
assert!(
got.ends_with("(invalid format)"),
"{v:?}: read('*z') error was `{got}`, expected to end with `(invalid format)`"
);
}
}
#[test]
fn bare_format_requires_star_on_51_52_crossversion() {
for v in ALL {
let code = "local f = io.open('x','r'); \
local ok, res = pcall(function() return f:read('l') end); \
f:close(); \
if ok then return 'OK:' .. tostring(res) \
else return 'ERR:' .. tostring(res) end";
let got = eval_str(v, b"firstline\nsecond\n", code);
match v {
LuaVersion::V51 | LuaVersion::V52 => assert!(
got.starts_with("ERR:") && got.ends_with("(invalid option)"),
"{v:?}: bare read('l') should error with (invalid option), got `{got}`"
),
_ => assert_eq!(
got, "OK:firstline",
"{v:?}: bare read('l') should read the first line"
),
}
}
}
#[test]
fn starred_line_format_accepted_everywhere() {
let code = "local f = io.open('x','r'); \
local line = f:read('*l'); \
f:close(); \
return tostring(line)";
for v in ALL {
let got = eval_str(v, b"firstline\nsecond\n", code);
assert_eq!(
got, "firstline",
"{v:?}: read('*l') should read the first line"
);
}
}
#[test]
fn line_with_eol_format_is_52_plus_crossversion() {
for v in ALL {
let code = "local f = io.open('x','r'); \
local ok, res = pcall(function() return f:read('*L') end); \
f:close(); \
if ok then return 'OK:' .. tostring(res) \
else return 'ERR:' .. tostring(res) end";
let got = eval_str(v, b"firstline\nsecond\n", code);
match v {
LuaVersion::V51 => assert!(
got.starts_with("ERR:") && got.ends_with("(invalid format)"),
"5.1: read('*L') should error (invalid format) — no L format, got `{got}`"
),
_ => assert_eq!(
got, "OK:firstline\n",
"{v:?}: read('*L') should return the line including its newline"
),
}
}
}
#[test]
fn read_number_parses_known_content_53_plus() {
let code = "local f = io.open('x','r'); \
local a, b, c = f:read('n', 'n', 'n'); \
f:close(); \
return tostring(a) .. '|' .. tostring(b) .. '|' .. tostring(c)";
for v in [LuaVersion::V53, LuaVersion::V54, LuaVersion::V55] {
let got = eval_str(v, b"42 3.14 0x1Ap2\n", code);
assert_eq!(
got, "42|3.14|104.0",
"{v:?}: read('n','n','n') over `42 3.14 0x1Ap2` should parse to 42, 3.14, 104.0"
);
}
}
#[test]
fn read_number_star_form_on_51_52() {
let code = "local f = io.open('x','r'); \
local a = f:read('*n'); \
f:close(); \
return tostring(a)";
for v in [LuaVersion::V51, LuaVersion::V52] {
let got = eval_str(v, b"42 3.14\n", code);
assert_eq!(
got, "42",
"{v:?}: read('*n') over `42 ...` should parse to 42"
);
}
}
#[test]
fn read_on_closed_file_errors_with_location_crossversion() {
let code = "local f = io.open('x','r'); \
f:close(); \
local ok, err = pcall(function() return f:read('*l') end); \
return tostring(err)";
for v in ALL {
let got = eval_str(v, b"data\n", code);
assert!(
got.ends_with("attempt to use a closed file"),
"{v:?}: closed-file error was `{got}`"
);
assert!(
got.contains("io_test:") && got != "attempt to use a closed file",
"{v:?}: closed-file error should carry a source-location prefix \
(like reference luaL_error), got `{got}`"
);
}
}
#[test]
fn io_type_reports_open_closed_and_non_handle_crossversion() {
let code = "local f = io.open('x','r'); \
local open = io.type(f); \
f:close(); \
local closed = io.type(f); \
local notfile = io.type('hello'); \
return tostring(open) .. '|' .. tostring(closed) .. '|' .. tostring(notfile)";
for v in ALL {
let got = eval_str(v, b"data\n", code);
assert_eq!(
got, "file|closed file|nil",
"{v:?}: io.type should report file / closed file / nil (fail, not false)"
);
}
}