diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index cdb6108f..6b3ebe93 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -170,24 +170,24 @@ I see a module in the code repo that is labelled the IO library, but it only cre | Status | Function | Differences | Notes | | ------ | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ----- | -| ⚫️ | `close([file])` | | | -| ⚫️ | `flush()` | | | -| ⚫️ | `input([file])` | | | -| ⚫️ | `lines([filename, args...])` | | | -| ⚫️ | `open(filename [, mode])` | | | -| | `output([file])` | | | +| 🔵 | `close([file])` | | | +| 🔵 | `flush()` | | | +| 🔵 | `input([file])` | | | +| 🔵 | `lines([filename, args...])` | | | +| 🔵 | `open(filename [, mode])` | | | +| 🔵 | `output([file])` | | | | ⚫️/❗ | `popen(prog[, mode])` | Might be classifiable as "C weirdness" or it's just creating another process which kinda feels as icky as the OS module imo | | -| ⚫️ | `read(args...)` | | | -| ⚫️ | `tmpfile()` | | | -| ⚫️ | `type(obj)` | | | -| ⚫️ | `write(args...)` | | | -| ⚫️ | `file:close()` | | | -| ⚫️ | `file:flush()` | | | -| ⚫️ | `file:lines(args...)` | | | -| ⚫️ | `file:read(args...)` | | | -| ⚫️ | `file:seek([whence, offset])` | | | -| ⚫️ | `file:setvbuf(mode[, size])` | | | -| ⚫️ | `file:write(args...)` | | | +| 🔵 | `read(args...)` | | | +| 🔵 | `tmpfile()` | | | +| 🔵 | `type(obj)` | | | +| 🔵 | `write(args...)` | | | +| 🔵 | `file:close()` | | | +| 🔵 | `file:flush()` | | | +| 🔵 | `file:lines(args...)` | | | +| 🔵 | `file:read(args...)` | | | +| 🔵 | `file:seek([whence, offset])` | | | +| ⚫️/❗ | `file:setvbuf(mode[, size])` | I think it's better not to touch this because it would require `libc` and `winapi`. | | +| 🔵 | `file:write(args...)` | | | ## OS diff --git a/Cargo.lock b/Cargo.lock index e3476b71..4789006c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ahash" @@ -136,6 +136,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "endian-type" version = "0.1.2" @@ -158,6 +164,12 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0474425d51df81997e2f90a21591180b38eccf27292d755f3e30750225c175b" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fd-lock" version = "4.0.2" @@ -286,10 +298,12 @@ dependencies = [ "allocator-api2", "anyhow", "clap", + "either", "gc-arena", "hashbrown", "rand", "rustyline", + "tempfile", "thiserror", ] @@ -462,6 +476,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fcd239983515c23a32fb82099f97d0b11b8c72f654ed659363a95c3dad7a53" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "1.0.61" diff --git a/Cargo.toml b/Cargo.toml index 06712a6e..c277a45d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,8 @@ gc-arena = { git = "https://github.com/kyren/gc-arena", rev = "5a7534b883b703f23 hashbrown = { version = "0.14", features = ["raw"] } rand = { version = "0.8", features = ["small_rng"] } serde = "1.0" +tempfile = "3" +either = "1" thiserror = "1.0" piccolo = { path = "./", version = "0.3.3" } @@ -44,6 +46,8 @@ anyhow.workspace = true gc-arena.workspace = true hashbrown.workspace = true rand.workspace = true +tempfile.workspace = true +either.workspace = true thiserror.workspace = true [dev-dependencies] diff --git a/README.md b/README.md index 4703b8be..99286aa5 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,7 @@ very much WIP, so ensuring this is done correctly is an ongoing effort. ## What currently doesn't work * A large amount of the stdlib is not implemented yet. Most "peripheral" parts - of the stdlib are this way, the `io`, `file`, `os`, `package`, `string`, + of the stdlib are this way, the `os`, `package`, `string`, `table`, and `utf8` libs are either missing or very sparsely implemented. * There is no support yet for finalization. `gc-arena` supports finalization in such a way now that it should be possible to implement `__gc` metamethods with diff --git a/src/lua.rs b/src/lua.rs index 67f03007..2ef03e31 100644 --- a/src/lua.rs +++ b/src/lua.rs @@ -72,6 +72,10 @@ impl<'gc> Context<'gc> { self.state.finalizers } + pub fn io_metatable(self) -> Table<'gc> { + self.state.io_metatable + } + // Calls `ctx.globals().get(key)` pub fn get_global>(self, key: &'static str) -> Result { self.state.globals.get(self, key) @@ -298,6 +302,7 @@ struct State<'gc> { registry: Registry<'gc>, strings: InternedStringSet<'gc>, finalizers: Finalizers<'gc>, + io_metatable: Table<'gc>, } impl<'gc> State<'gc> { @@ -307,6 +312,7 @@ impl<'gc> State<'gc> { registry: Registry::new(mc), strings: InternedStringSet::new(mc), finalizers: Finalizers::new(mc), + io_metatable: Table::new(mc), } } diff --git a/src/meta_ops.rs b/src/meta_ops.rs index 6fd0517a..d8c25f95 100644 --- a/src/meta_ops.rs +++ b/src/meta_ops.rs @@ -4,6 +4,7 @@ use gc_arena::Collect; use thiserror::Error; use crate::async_callback::{AsyncSequence, Locals}; +use crate::stdlib::IoFile; use crate::{async_sequence, SequenceReturn, Stack}; use crate::{ table::InvalidTableKey, Callback, CallbackReturn, Context, Function, IntoValue, Table, Value, @@ -203,6 +204,13 @@ pub fn index<'gc>( idx } + Value::UserData(u) if u.downcast_static::().is_ok() => { + let idx = ctx.io_metatable().get_value(ctx, MetaMethod::Index); + if idx.is_nil() { + return Ok(MetaResult::Value(Value::Nil)); + } + idx + } Value::UserData(u) if u.metatable().is_some() => { let idx = if let Some(mt) = u.metatable() { mt.get_value(ctx, MetaMethod::Index) @@ -223,7 +231,7 @@ pub fn index<'gc>( return Err(MetaOperatorError::Unary( MetaMethod::Index, table.type_name(), - )) + )); } }; diff --git a/src/stdlib/io.rs b/src/stdlib/io.rs index 39254fcd..8395b6f3 100644 --- a/src/stdlib/io.rs +++ b/src/stdlib/io.rs @@ -1,17 +1,36 @@ +use either::Either; +use gc_arena::Collect; use std::{ - io::{self, Write}, + cell::RefCell, + fs::OpenOptions, + io::{self, Seek, SeekFrom, Write}, pin::Pin, }; -use gc_arena::Collect; +mod file; +mod state; +mod std_file_kind; +use self::{state::IoState, std_file_kind::StdFileKind}; use crate::{ meta_ops::{self, MetaResult}, - BoxSequence, Callback, CallbackReturn, Context, Error, Execution, Sequence, SequencePoll, - Stack, Value, + BoxSequence, Callback, CallbackReturn, Context, Error, Execution, IntoValue, MetaMethod, + Sequence, SequencePoll, Stack, String, Table, UserData, Value, }; +pub use file::IoFile; + pub fn load_io<'gc>(ctx: Context<'gc>) { + thread_local! { + static IO_STATE: IoState = IoState::new(); + } + + let io = Table::new(&ctx); + + io.set_field(ctx, "stdin", UserData::new_static(&ctx, IoFile::stdin())); + io.set_field(ctx, "stdout", UserData::new_static(&ctx, IoFile::stdout())); + io.set_field(ctx, "stderr", UserData::new_static(&ctx, IoFile::stderr())); + ctx.set_global( "print", Callback::from_fn(&ctx, |ctx, _, mut stack| { @@ -69,4 +88,831 @@ pub fn load_io<'gc>(ctx: Context<'gc>) { ))) }), ); + + io.set_field( + ctx, + "open", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + let (filename, mode) = stack.consume::<(String, Option)>(ctx)?; + let filename = filename.to_str()?; + let mode = mode.map(|s| s.to_str()).unwrap_or(Ok("r"))?; + + let mut file = OpenOptions::new(); + + let mode = mode.replace("b", ""); + + let file = match mode.as_str() { + "r" => file.read(true), + "w" => file.write(true).create(true).truncate(true), + "a" => file.write(true).create(true).append(true), + "r+" => file.read(true).write(true), + "w+" => file.read(true).write(true).create(true).truncate(true), + "a+" => file.read(true).write(true).create(true).append(true), + _ => return Err("invalid `mode`".into_value(ctx).into()), + }; + + match file.open(filename) { + Ok(file) => { + stack.replace(ctx, UserData::new_static(&ctx, IoFile::new(file))); + Ok(CallbackReturn::Return) + } + Err(err) => { + stack.replace( + ctx, + (Value::Nil, err.to_string(), err.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } + } + }), + ); + + io.set_field( + ctx, + "input", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + #[derive(Collect)] + #[collect(no_drop)] + struct InputOpenThen; + + impl<'gc> Sequence<'gc> for InputOpenThen { + fn poll( + self: Pin<&mut Self>, + ctx: Context<'gc>, + _exec: Execution<'gc, '_>, + stack: Stack<'gc, '_>, + ) -> Result, Error<'gc>> { + let file: Value = stack.get(0); + + if file.is_nil() { + return Ok(SequencePoll::Return); + } + + match file { + Value::UserData(file) => { + if let Ok(file) = file.downcast_static::() { + IO_STATE.with(|state| state.replace_input(file.clone())); + Ok(SequencePoll::Return) + } else { + Err("bad argument #1 to 'input' (file expected)" + .into_value(ctx) + .into()) + } + } + _ => Err("bad argument #1 to 'input' (file expected)" + .into_value(ctx) + .into()), + } + } + } + + if stack.is_empty() { + stack.replace( + ctx, + UserData::new_static(&ctx, IO_STATE.with(|state| state.input())), + ); + return Ok(CallbackReturn::Return); + } + + let value = stack.consume::(ctx)?; + + match value { + Value::String(filename) => { + let io: Table = ctx.globals().get(ctx, "io")?; + let open: Callback = io.get(ctx, "open")?; + + stack.replace(ctx, (filename, "r")); + Ok(CallbackReturn::Call { + function: open.into(), + then: Some(BoxSequence::new(&ctx, InputOpenThen)), + }) + } + Value::UserData(file) => { + if let Ok(file) = file.downcast_static::() { + IO_STATE.with(|state| state.replace_input(file.clone())); + + stack.replace(ctx, UserData::new_static(&ctx, file.clone())); + Ok(CallbackReturn::Return) + } else { + Err("expected `file`".into_value(ctx).into()) + } + } + _ => Err("expected `string` or `file`".into_value(ctx).into()), + } + }), + ); + + io.set_field( + ctx, + "output", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + #[derive(Collect)] + #[collect(no_drop)] + struct OutputOpenThen; + + impl<'gc> Sequence<'gc> for OutputOpenThen { + fn poll( + self: Pin<&mut Self>, + ctx: Context<'gc>, + _exec: Execution<'gc, '_>, + stack: Stack<'gc, '_>, + ) -> Result, Error<'gc>> { + let file: Value = stack.get(0); + + if file.is_nil() { + return Ok(SequencePoll::Return); + } + + match file { + Value::UserData(file) => { + if let Ok(file) = file.downcast_static::() { + IO_STATE.with(|state| state.replace_output(file.clone())); + Ok(SequencePoll::Return) + } else { + Err("bad argument #1 to 'input' (file expected)" + .into_value(ctx) + .into()) + } + } + _ => Err("bad argument #1 to 'input' (file expected)" + .into_value(ctx) + .into()), + } + } + } + + if stack.is_empty() { + stack.replace( + ctx, + UserData::new_static(&ctx, IO_STATE.with(|state| state.output())), + ); + return Ok(CallbackReturn::Return); + } + + let value = stack.consume::(ctx)?; + + match value { + Value::String(filename) => { + let io: Table = ctx.globals().get(ctx, "io")?; + let open: Callback = io.get(ctx, "open")?; + + stack.replace(ctx, (filename, "w")); + return Ok(CallbackReturn::Call { + function: open.into(), + then: Some(BoxSequence::new(&ctx, OutputOpenThen)), + }); + } + Value::UserData(file) => { + if let Ok(file) = file.downcast_static::() { + IO_STATE.with(|state| state.replace_output(file.clone())); + + stack.replace(ctx, UserData::new_static(&ctx, file.clone())); + Ok(CallbackReturn::Return) + } else { + Err("expected `file`".into_value(ctx).into()) + } + } + _ => Err("expected `string` or `file`".into_value(ctx).into()), + } + }), + ); + + io.set_field( + ctx, + "close", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + let output = IO_STATE.with(|state| state.output()); + + if !output.is_std() { + if let Err(err) = output.close() { + stack.replace( + ctx, + (Value::Nil, err.to_string(), err.raw_os_error().unwrap_or(0)), + ); + return Ok(CallbackReturn::Return); + } + IO_STATE.with(|state| state.replace_output(IoFile::stdout())); + stack.replace(ctx, true); + return Ok(CallbackReturn::Return); + } + + stack.replace(ctx, true); + return Ok(CallbackReturn::Return); + } + + let file: UserData = stack.consume(ctx)?; + let file_wrapper = if let Ok(fw) = file.downcast_static::() { + fw + } else { + return Err("expected `file`".into_value(ctx).into()); + }; + + match file_wrapper.close() { + Ok(_) => { + stack.replace(ctx, true); + Ok(CallbackReturn::Return) + } + Err(e) => { + stack.replace( + ctx, + (Value::Nil, e.to_string(), e.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } + } + }), + ); + + io.set_field( + ctx, + "flush", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + let output = IO_STATE.with(|state| state.output()); + + if let Err(err) = output.flush() { + stack.replace( + ctx, + (Value::Nil, err.to_string(), err.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } else { + stack.replace(ctx, UserData::new_static(&ctx, output.clone())); + Ok(CallbackReturn::Return) + } + }), + ); + + io.set_field( + ctx, + "read", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + let input = IO_STATE.with(|state| state.input()); + + let file: Table = ctx.io_metatable().get(ctx, MetaMethod::Index)?; + let read: Callback = file.get(ctx, "read")?; + + stack.into_front(ctx, Value::UserData(UserData::new_static(&ctx, input))); + + Ok(CallbackReturn::Call { + function: read.into(), + then: None, + }) + }), + ); + + io.set_field( + ctx, + "write", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + let output = IO_STATE.with(|state| state.output()); + let file: Table = ctx.io_metatable().get(ctx, MetaMethod::Index)?; + let write: Callback = file.get(ctx, "write")?; + + stack.into_front(ctx, Value::UserData(UserData::new_static(&ctx, output))); + + Ok(CallbackReturn::Call { + function: write.into(), + then: None, + }) + }), + ); + + io.set_field( + ctx, + "lines", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + #[derive(Collect)] + #[collect(no_drop)] + enum LinesState { + Open, + Lines, + } + + #[derive(Collect)] + #[collect(no_drop)] + struct LinesOpenThen<'gc> { + open: Callback<'gc>, + lines: Callback<'gc>, + formats: Vec>, + state: LinesState, + } + + impl<'gc> LinesOpenThen<'gc> { + fn new( + open: Callback<'gc>, + lines: Callback<'gc>, + formats: Vec>, + ) -> Self { + Self { + open, + lines, + formats, + state: LinesState::Open, + } + } + } + + impl<'gc> Sequence<'gc> for LinesOpenThen<'gc> { + fn poll( + mut self: Pin<&mut Self>, + ctx: Context<'gc>, + _exec: Execution<'gc, '_>, + mut stack: Stack<'gc, '_>, + ) -> Result, Error<'gc>> { + match self.state { + LinesState::Open => { + self.state = LinesState::Lines; + Ok(SequencePoll::Call { + function: self.open.into(), + bottom: 0, + }) + } + LinesState::Lines => { + let file: Value = stack.consume(ctx)?; + + if file.is_nil() { + return Ok(SequencePoll::Return); + } + + stack.clear(); + stack.into_back(ctx, file); + self.formats.iter().for_each(|format| { + stack.into_back(ctx, *format); + }); + + Ok(SequencePoll::Call { + function: self.lines.into(), + bottom: 0, + }) + } + } + } + } + + let file: Table = ctx.io_metatable().get(ctx, MetaMethod::Index)?; + let lines: Callback = file.get(ctx, "lines")?; + + if stack.is_empty() { + let input = IO_STATE.with(|state| state.input()); + + stack.replace(ctx, (UserData::new_static(&ctx, input), "l")); + + Ok(CallbackReturn::Call { + function: lines.into(), + then: None, + }) + } else { + let filename = stack.consume(ctx)?; + if let Value::String(_) = filename { + let io: Table = ctx.io_metatable().get(ctx, MetaMethod::Index)?; + let open: Callback = io.get(ctx, "open")?; + + let formats = if stack.is_empty() { + vec![ctx.intern(b"l").into_value(ctx)] // default format + } else { + stack.into_iter().collect::>() + }; + + stack.replace(ctx, (filename, "r")); + + Ok(CallbackReturn::Sequence(BoxSequence::new( + &ctx, + LinesOpenThen::new(open, lines, formats), + ))) + } else { + Err("bad argument #1 to 'lines' (string expected)" + .into_value(ctx) + .into()) + } + } + }), + ); + + io.set_field( + ctx, + "type", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'type' (value expected)" + .into_value(ctx) + .into()); + } + + let file: Value = stack.consume(ctx)?; + + match file { + Value::UserData(file) => { + if let Ok(file) = file.downcast_static::() { + if file.is_some() { + stack.replace(ctx, "file") + } else { + stack.replace(ctx, "closed file") + } + } else { + stack.replace(ctx, Value::Nil); + } + } + _ => stack.replace(ctx, Value::Nil), + } + + Ok(CallbackReturn::Return) + }), + ); + + io.set_field( + ctx, + "tmpfile", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + let tmpfile = tempfile::tempfile()?; + + stack.replace(ctx, UserData::new_static(&ctx, IoFile::new(tmpfile))); + + Ok(CallbackReturn::Return) + }), + ); + + let file = Table::new(&ctx); + + ctx.io_metatable() + .set(ctx, MetaMethod::Index, file) + .unwrap(); + + file.set_field( + ctx, + "close", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'close' (file expected)" + .into_value(ctx) + .into()); + } + + let file: UserData = stack.consume(ctx)?; + let file = if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'close' (file expected)" + .into_value(ctx) + .into()); + }; + + match file.close() { + Ok(_) => { + stack.replace(ctx, true); + Ok(CallbackReturn::Return) + } + Err(e) => { + stack.replace( + ctx, + (Value::Nil, e.to_string(), e.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } + } + }), + ); + + file.set_field( + ctx, + "flush", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'flush' (file expected)" + .into_value(ctx) + .into()); + } + + let file: UserData = stack.consume(ctx)?; + let file = if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'flush' (file expected)" + .into_value(ctx) + .into()); + }; + + match file.flush() { + Ok(_) => { + stack.replace(ctx, UserData::new_static(&ctx, file.clone())); + Ok(CallbackReturn::Return) + } + Err(e) => { + stack.replace( + ctx, + (Value::Nil, e.to_string(), e.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } + } + }), + ); + + file.set_field( + ctx, + "read", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'read' (file expected)" + .into_value(ctx) + .into()); + } + + let Some(file) = stack.pop_front() else { + return Err("bad argument #1 to 'read' (file expected)" + .into_value(ctx) + .into()); + }; + let file = if let Value::UserData(file) = file { + if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'read' (file expected)" + .into_value(ctx) + .into()); + } + } else { + return Err("bad argument #1 to 'read' (file expected)" + .into_value(ctx) + .into()); + }; + + let formats = stack + .into_iter() + .enumerate() + .map(|(n, value)| { + let Some(format) = value.into_string(ctx) else { + return Err(format!( + "bad argument #{} to 'read' (string expected, got {})", + n + 1, + value.type_name() + ) + .into_value(ctx) + .into()); + }; + match format.to_str() { + Ok(s) => Ok(s), + Err(err) => Err(err.to_string().into_value(ctx).into()), + } + }) + .collect::, Error<'_>>>()?; + + stack.clear(); + for format in formats { + match file.read_with_format(ctx, format)? { + Some(value) => { + stack.into_back(ctx, value); + } + None => { + stack.into_back(ctx, Value::Nil); + } + } + } + + Ok(CallbackReturn::Return) + }), + ); + + file.set_field( + ctx, + "lines", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + #[derive(Collect, Clone)] + #[collect(require_static)] + struct Lines { + position: RefCell, + file: IoFile, + formats: Vec, + } + + impl<'gc> Sequence<'gc> for Lines { + fn poll( + self: Pin<&mut Self>, + ctx: Context<'gc>, + _exec: Execution<'gc, '_>, + mut stack: Stack<'gc, '_>, + ) -> Result, Error<'gc>> { + let mut position = self.position.borrow_mut(); + if let Some(format) = self.formats.get(*position) { + match self.file.read_with_format(ctx, format)? { + Some(value) => { + stack.into_back(ctx, value); + } + None => {} + } + *position += 1; + return Ok(SequencePoll::Return); + } else { + return Ok(SequencePoll::Return); + } + } + } + + if stack.is_empty() { + return Err("bad argument #1 to 'lines' (file expected)" + .into_value(ctx) + .into()); + } + + let file: Value = stack.get(0); + let file = if let Value::UserData(file) = file { + if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'lines' (file expected)" + .into_value(ctx) + .into()); + } + } else { + return Err("bad argument #1 to 'lines' (file expected)" + .into_value(ctx) + .into()); + }; + + let values = if stack.is_empty() { + vec![ctx.intern(b"l").into_value(ctx)] // default format + } else { + stack.into_iter().skip(1).collect::>() + }; + + let formats = values + .into_iter() + .enumerate() + .map(|(n, value)| { + let Some(format) = value.into_string(ctx) else { + return Err(format!( + "bad argument #{} to 'lines' (string expected, got {})", + n + 1, + value.type_name() + ) + .into_value(ctx) + .into()); + }; + match format.to_str() { + Ok(s) => Ok(s.to_string()), + Err(err) => Err(err.to_string().into_value(ctx).into()), + } + }) + .collect::, Error<'_>>>()?; + + let root = Lines { + position: RefCell::new(0), + file: file.clone(), + formats, + }; + + let lines = Callback::from_fn_with(&ctx, root, |root, ctx, _, _| { + Ok(CallbackReturn::Sequence(BoxSequence::new( + &ctx, + root.clone(), + ))) + }); + + stack.replace(ctx, lines); + Ok(CallbackReturn::Return) + }), + ); + + file.set_field( + ctx, + "seek", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'seek' (file expected)" + .into_value(ctx) + .into()); + } + + let (file, whence, offset) = + stack.consume::<(UserData, Option, Option)>(ctx)?; + let file = if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'seek' (file expected)" + .into_value(ctx) + .into()); + }; + + let whence_str = whence.map(|s| s.to_str().unwrap_or("cur")).unwrap_or("cur"); + let offset = offset.unwrap_or(0); + + let seek_from = match whence_str { + "set" => SeekFrom::Start(offset as u64), + "cur" => SeekFrom::Current(offset), + "end" => SeekFrom::End(offset), + _ => { + return Err("bad argument #2 to 'seek' (invalid option)" + .into_value(ctx) + .into()) + } + }; + + match file.inner() { + Either::Left(left) => { + let mut left = left.borrow_mut(); + if let Some(ref mut left) = *left { + match left.seek(seek_from) { + Ok(position) => { + file.set_read_position(position as usize); + stack.replace(ctx, position as i64); + Ok(CallbackReturn::Return) + } + Err(err) => { + stack.replace( + ctx, + (Value::Nil, err.to_string(), err.raw_os_error().unwrap_or(0)), + ); + Ok(CallbackReturn::Return) + } + } + } else { + Err("attempt to use a closed file".into_value(ctx).into()) + } + } + Either::Right(_) => { + stack.replace(ctx, (Value::Nil, "Illegal seek", 29)); + Ok(CallbackReturn::Return) + } + } + }), + ); + + file.set_field( + ctx, + "write", + Callback::from_fn(&ctx, |ctx, _, mut stack| { + if stack.is_empty() { + return Err("bad argument #1 to 'write' (file expected)" + .into_value(ctx) + .into()); + } + + let Some(file) = stack.pop_front() else { + return Err("bad argument #1 to 'write' (file expected)" + .into_value(ctx) + .into()); + }; + let file = if let Value::UserData(file) = file { + if let Ok(file) = file.downcast_static::() { + file + } else { + return Err("bad argument #1 to 'write' (file expected)" + .into_value(ctx) + .into()); + } + } else { + return Err("bad argument #1 to 'write' (file expected)" + .into_value(ctx) + .into()); + }; + let values = stack + .into_iter() + .enumerate() + .map(|(n, value)| { + let Some(s) = value.into_string(ctx) else { + return Err(format!( + "bad argument #{} to 'write' (string expected, got {})", + n + 1, + value.type_name() + ) + .into_value(ctx) + .into()); + }; + match s.to_str() { + Ok(s) => Ok(s), + Err(err) => Err(err.to_string().into_value(ctx).into()), + } + }) + .collect::, Error<'_>>>()?; + + match file.inner() { + Either::Left(left) => { + let mut left = left.borrow_mut(); + if let Some(ref mut file) = *left { + for value in values { + file.write_all(value.as_bytes())?; + } + } + stack.replace(ctx, UserData::new_static(&ctx, file.clone())); + Ok(CallbackReturn::Return) + } + Either::Right(kind) => { + let mut output: Box = match kind { + StdFileKind::Stdout => Box::new(io::stdout()), + StdFileKind::Stderr => Box::new(io::stderr()), + StdFileKind::Stdin => { + return Err("attempt to write to stdin".into_value(ctx).into()) + } + }; + for value in values { + output.write_all(value.as_bytes())?; + } + stack.replace(ctx, UserData::new_static(&ctx, file.clone())); + Ok(CallbackReturn::Return) + } + } + }), + ); + + ctx.set_global("io", io); } diff --git a/src/stdlib/io/file.rs b/src/stdlib/io/file.rs new file mode 100644 index 00000000..298373f0 --- /dev/null +++ b/src/stdlib/io/file.rs @@ -0,0 +1,323 @@ +use crate::{Context, Error, IntoValue, Value}; +use std::{ + cell::RefCell, + fs::File, + io::{self, Cursor, Read, Seek, Write}, + rc::Rc, +}; + +use super::std_file_kind::StdFileKind; +use either::Either; +use gc_arena::Collect; + +fn read_from_any<'gc, W: Write + Read + Seek>( + ctx: Context<'gc>, + file: &mut W, + format: &str, + position: &mut usize, +) -> Result>, Error<'gc>> { + fn seek_read<'gc, W: Write + Read + Seek>( + file: &mut W, + position: &mut usize, + buf: &mut [u8], + ) -> io::Result { + file.seek(io::SeekFrom::Start(*position as u64))?; + match file.read(buf) { + Ok(n) => { + *position += n; + Ok(n) + } + Err(err) => Err(err), + } + } + + match format { + "l" => { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + + loop { + match seek_read(file, position, &mut byte) { + Ok(0) => { + if buf.is_empty() { + return Ok(None); + } + break; + } + Ok(_) => { + if byte[0] == b'\n' { + break; + } else if byte[0] == b'\r' { + let mut next_byte = [0u8; 1]; + match seek_read(file, position, &mut next_byte) { + Ok(1) => { + if next_byte[0] != b'\n' { + // Wasn't \r\n, but we can't put it back if the stream doesn't support seek + // We'll just consider this a part of the next line when reading continues + } + } + _ => {} + } + break; + } else { + buf.push(byte[0]); + } + } + Err(err) => return Err(err.to_string().into_value(ctx).into()), + } + } + + Ok(Some(ctx.intern(&buf).into())) + } + + "L" => { + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + + loop { + match seek_read(file, position, &mut byte) { + Ok(0) => { + if buf.is_empty() { + return Ok(None); + } + break; + } + Ok(_) => { + buf.push(byte[0]); + if byte[0] == b'\n' { + break; + } else if byte[0] == b'\r' { + let mut next_byte = [0u8; 1]; + match seek_read(file, position, &mut next_byte) { + Ok(1) => { + if next_byte[0] == b'\n' { + *position += 1; + buf.push(next_byte[0]); + } else { + // Again, we can't put back what we read in a generic Read + // This byte will be part of the next line + } + break; + } + _ => break, + } + } + } + Err(err) => return Err(err.to_string().into_value(ctx).into()), + } + } + + Ok(Some(ctx.intern(&buf).into())) + } + + "a" => { + let mut buf = Vec::new(); + match file.read_to_end(&mut buf) { + Ok(0) => Ok(Some(ctx.intern(&[]).into())), + Ok(n) => { + *position += n; + Ok(Some(ctx.intern(&buf).into())) + } + Err(err) => Err(err.to_string().into_value(ctx).into()), + } + } + + "n" => { + let start_position = *position; + let mut buf = Vec::new(); + let mut byte = [0u8; 1]; + let mut has_digit = false; + + loop { + match seek_read(file, position, &mut byte) { + Ok(0) => return Ok(None), + Ok(_) => { + if byte[0].is_ascii_whitespace() { + continue; + } + buf.push(byte[0]); + has_digit |= byte[0].is_ascii_digit(); + break; + } + Err(e) => return Err(e.to_string().into_value(ctx).into()), + } + } + + loop { + match seek_read(file, position, &mut byte) { + Ok(0) => break, + Ok(_) => { + if matches!(byte[0], b'0'..=b'9' | b'.' | b'-' | b'+' | b'e' | b'E') { + buf.push(byte[0]); + if byte[0].is_ascii_digit() { + has_digit = true; + } + } else { + break; + } + } + Err(err) => return Err(err.to_string().into_value(ctx).into()), + } + } + + if !has_digit { + *position = start_position; + return Ok(None); + } + + let s = std::string::String::from_utf8_lossy(&buf); + match s + .parse::() + .ok() + .map(Value::from) + .or_else(|| s.parse::().ok().map(Value::from)) + { + Some(value) => Ok(Some(value)), + None => { + *position = start_position; + Ok(None) + } + } + } + + _ => { + if let Ok(n) = format.parse::() { + if n == 0 { + let mut empty_buf: [u8; 0] = []; + file.seek(io::SeekFrom::Start(*position as u64))?; + match file.read_exact(&mut empty_buf) { + Ok(_) => return Ok(Some(ctx.intern(&[]).into())), + Err(_) => return Ok(None), + } + } + + let mut buf = vec![0u8; n]; + match seek_read(file, position, &mut buf) { + Ok(0) => Ok(None), + Ok(bytes_read) => { + if bytes_read == 0 { + Ok(None) + } else if bytes_read < n { + Ok(None) + } else { + Ok(Some(ctx.intern(&buf).into())) + } + } + Err(err) => Err(err.to_string().into_value(ctx).into()), + } + } else { + Err("invalid format".into_value(ctx).into()) + } + } + } +} + +#[derive(Collect, Clone)] +#[collect(require_static)] +pub struct IoFile { + inner: Rc>, StdFileKind>>, + read_position: RefCell, +} + +impl IoFile { + pub fn new(file: File) -> Self { + Self { + inner: Rc::new(Either::Left(RefCell::new(Some(file)))), + read_position: RefCell::new(0), + } + } + pub fn stdin() -> Self { + Self { + inner: Rc::new(Either::Right(StdFileKind::Stdin)), + read_position: RefCell::new(0), + } + } + pub fn stdout() -> Self { + Self { + inner: Rc::new(Either::Right(StdFileKind::Stdout)), + read_position: RefCell::new(0), + } + } + pub fn stderr() -> Self { + Self { + inner: Rc::new(Either::Right(StdFileKind::Stderr)), + read_position: RefCell::new(0), + } + } + pub fn is_std(&self) -> bool { + matches!(self.inner.as_ref(), Either::Right(_)) + } + pub fn close(&self) -> Result<(), io::Error> { + if self.is_std() { + Ok(()) + } else { + let Either::Left(left) = self.inner.as_ref() else { + unreachable!() + }; + + let mut file_lock = left.borrow_mut(); + if let Some(file_handle) = file_lock.take() { + file_handle.sync_all()?; + } + Ok(()) + } + } + pub fn flush(&self) -> Result<(), io::Error> { + match self.inner() { + Either::Left(left) => { + let mut file_lock = left.borrow_mut(); + if let Some(ref mut file_handle) = *file_lock { + file_handle.flush()?; + } + Ok(()) + } + Either::Right(kind) => { + if matches!(kind, StdFileKind::Stdout | StdFileKind::Stderr) { + io::stdout().flush() + } else { + Ok(()) + } + } + } + } + pub fn read_with_format<'gc>( + &self, + ctx: Context<'gc>, + format: &str, + ) -> Result>, Error<'gc>> { + match self.inner() { + Either::Left(left) => { + let mut file = left.borrow_mut(); + let file = match *file { + Some(ref mut file) => file, + None => return Err("attempt to use a closed file".into_value(ctx).into()), + }; + let mut read_state = self.read_position.borrow_mut(); + read_from_any(ctx, file, format, &mut read_state) + } + Either::Right(kind) => match kind { + StdFileKind::Stdin => { + let mut read_bytes = self.read_position.borrow_mut(); + let mut stdin = io::stdin(); + let mut buf = Vec::new(); + stdin.read_exact(&mut buf)?; + let mut cursor = Cursor::new(buf); + read_from_any(ctx, &mut cursor, format, &mut read_bytes) + } + _ => Err("attempt to read from output file".into_value(ctx).into()), + }, + } + } + pub fn is_some(&self) -> bool { + match self.inner.as_ref() { + Either::Left(left) => left.borrow().is_some(), + Either::Right(_) => true, + } + } + pub fn inner(&self) -> &Either>, StdFileKind> { + self.inner.as_ref() + } + pub fn set_read_position(&self, state: usize) { + self.read_position.replace(state); + } +} diff --git a/src/stdlib/io/state.rs b/src/stdlib/io/state.rs new file mode 100644 index 00000000..d5bf5e0d --- /dev/null +++ b/src/stdlib/io/state.rs @@ -0,0 +1,31 @@ +use super::file::IoFile; +use gc_arena::Collect; +use std::cell::RefCell; + +#[derive(Collect, Clone)] +#[collect(no_drop)] +pub struct IoState { + input: RefCell, + output: RefCell, +} + +impl IoState { + pub fn new() -> Self { + Self { + input: RefCell::new(IoFile::stdin()), + output: RefCell::new(IoFile::stdout()), + } + } + pub fn replace_input(&self, input: IoFile) { + self.input.replace(input); + } + pub fn replace_output(&self, output: IoFile) { + self.output.replace(output); + } + pub fn input(&self) -> IoFile { + self.input.borrow().clone() + } + pub fn output(&self) -> IoFile { + self.output.borrow().clone() + } +} diff --git a/src/stdlib/io/std_file_kind.rs b/src/stdlib/io/std_file_kind.rs new file mode 100644 index 00000000..df73f296 --- /dev/null +++ b/src/stdlib/io/std_file_kind.rs @@ -0,0 +1,9 @@ +use gc_arena::Collect; + +#[derive(Collect, Clone, Copy)] +#[collect(no_drop)] +pub enum StdFileKind { + Stdin, + Stdout, + Stderr, +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index aa766153..cfe63ab9 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -6,6 +6,10 @@ mod string; mod table; pub use self::{ - base::load_base, coroutine::load_coroutine, io::load_io, math::load_math, string::load_string, + base::load_base, + coroutine::load_coroutine, + io::{load_io, IoFile}, + math::load_math, + string::load_string, table::load_table, }; diff --git a/tests/scripts/io-test-input b/tests/scripts/io-test-input new file mode 100644 index 00000000..ed6d56f1 --- /dev/null +++ b/tests/scripts/io-test-input @@ -0,0 +1,7 @@ +0xFFFFF +Hello World! +How Are You? +Are you okay? +Hi!!! +0x12345 +5435 \ No newline at end of file diff --git a/tests/scripts/io-test-output b/tests/scripts/io-test-output new file mode 100644 index 00000000..920372bc --- /dev/null +++ b/tests/scripts/io-test-output @@ -0,0 +1,4 @@ +First Line +Second Line +Some number: 12345 +777 diff --git a/tests/scripts/io.lua b/tests/scripts/io.lua new file mode 100644 index 00000000..3269e3e5 --- /dev/null +++ b/tests/scripts/io.lua @@ -0,0 +1,90 @@ +local input = "./tests/scripts/io-test-input" +local output = "./tests/scripts/io-test-output" + +do + io.input(io.stdin) + assert(io.read("0") == "") + io.input(input) + assert(io.read("l") == "0xFFFFF") + io.input():close() + assert(io.type(io.input()) == "closed file") + io.output(output) + file = io.write("This is output?"):write("\n") + file:flush() + file:close() + assert(io.type(file) == "closed file") + assert(io.type(io.output()) == "closed file") +end + +do + local file = io.open(output, "w") + file:write("First Line\n"):write("Second Line\n"):write("Some number: 12345\n"):write("777\n") + file:flush() + file:close() + assert(io.type(file) == "closed file") + local file = io.open(output, "r") + assert(file:read("l") == "First Line") + assert(file:read("l") == "Second Line") + assert(file:read("l") == "Some number: 12345") + file:flush() + file:close() +end + +do + local file = io.open(output, "r") + assert(file:read("L") == "First Line\n") + assert(file:read("a") == "Second Line\nSome number: 12345\n777\n") + file:close() + local file = io.open(output, "r") + assert(file:read("l") == "First Line") + assert(file:read("l") == "Second Line") + assert(file:read("n") == nil) + assert(file:read("L") == "Some number: 12345\n") + assert(file:read("n") == 777) + file:flush() + file:close() +end + +do + local file = io.open(output, "r") + local a, b, c, d, e = file:read("n", "l", "l", "L", "n") + assert(a == nil) + assert(b == "First Line") + assert(c == "Second Line") + assert(d == "Some number: 12345\n") + assert(e == 777) + file:close() +end + +do + local file = io.open(output, "r") + file:seek("set", 1) + assert(file:read("l") == "irst Line") + file:seek("cur", 1) + assert(file:read("l") == "econd Line") + file:seek("set", 0) + assert(file:read("l") == "First Line") + file:seek("cur", 2) + assert(file:read("l") == "cond Line") + file:seek("end", -5) + assert(file:read("n") == 777) + file:seek("set", 11) + file:seek("cur", -2) + assert(file:read("l") == "e") + file:seek("set", 0) + local a, b = file:read("l", "l") + assert(a == "First Line") + assert(b == "Second Line") + file:seek("set", 35) + assert(file:read("n") == 12345) + file:seek("set", 15) + assert(file:read(10)== "nd Line\nSo") + file:close() +end + +do + tmp = io.tmpfile() + tmp:write("1234\n"):write("4321\n") + assert(tmp:read("l") == "1234") + assert(tmp:read("n") == 4321) +end \ No newline at end of file