diff --git a/AGENTS.md b/AGENTS.md index e7f4ba88..fd90a2c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ self-contained component you can use directly or copy as a starting point. | `vihaco-module` | Loadable `Module` model, `ProgramLoader`, host-VM traits (`ProgramCounter`, …), assembly-style `Display`. | | `vihaco-runtime` | Component/machine runtime: `GeneratedComponent`, `Effects` sinks, observation machinery, `CompositeMetadata`. Re-exports its derives via its `derive` feature. | | `vihaco-runtime-derive` | The proc macros behind `#[derive(Message/Machine)]` and `#[component]` / `#[composite]` / `#[observe]`. Consumed via `vihaco-runtime`. | -| `vihaco-stdlib` | Standard-library components and observers, currently including `StdoutObserver`. | +| `vihaco-stdlib` | Standard-library components and observers, including `StdoutObserver` with buffer capture (default), process stdout, and owned-file destinations. | | `vihaco-syntax` | Typed SST parsing and module construction (`Resolve`). | | `vihaco-parser` | The `Parse<'src>` and `SurfaceInstruction` traits plus lexical, primitive, and collection impls shared by the parser derive. | | `vihaco-parser-derive` | `#[derive(Parse)]` — turns instruction, value, and type enums or structs into [chumsky](https://github.com/zesterer/chumsky) parsers via `#[syntax_class]` and `#[pattern]` (see `attr.rs`/`codegen.rs`). | diff --git a/Cargo.lock b/Cargo.lock index edfef1ab..c316b1d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,12 @@ dependencies = [ "object", ] +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + [[package]] name = "byteorder" version = "1.5.0" @@ -186,6 +192,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "eyre" version = "0.6.12" @@ -196,6 +212,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -208,6 +230,17 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "glob" version = "0.3.3" @@ -289,6 +322,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.30" @@ -374,6 +413,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.12.3" @@ -420,6 +465,19 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "serde" version = "1.0.228" @@ -514,6 +572,19 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -753,6 +824,7 @@ name = "vihaco-stdlib" version = "0.4.0" dependencies = [ "eyre", + "tempfile", "vihaco-runtime", ] diff --git a/crates/vihaco-stdlib/Cargo.toml b/crates/vihaco-stdlib/Cargo.toml index 5e76545a..e9ff27a4 100644 --- a/crates/vihaco-stdlib/Cargo.toml +++ b/crates/vihaco-stdlib/Cargo.toml @@ -10,3 +10,6 @@ authors.workspace = true [dependencies] eyre = { workspace = true } vihaco-runtime = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/vihaco-stdlib/src/observer/stdio.rs b/crates/vihaco-stdlib/src/observer/stdio.rs index 7fc89586..9bba2dc0 100644 --- a/crates/vihaco-stdlib/src/observer/stdio.rs +++ b/crates/vihaco-stdlib/src/observer/stdio.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: 2026 The vihaco Authors // SPDX-License-Identifier: MIT -use std::io::Write; +use std::fs::File; +use std::io::{self, Write}; use eyre::Result; use vihaco_runtime::{Effects, observe}; @@ -9,19 +10,84 @@ use vihaco_runtime::{Effects, observe}; #[derive(Debug, Clone)] pub struct StdoutEffect(pub String); -#[derive(Debug, Default)] +/// Writes VM text to a buffer, process stdout, or an owned file. +/// +/// The default destination is an empty buffer. Stdout and file destinations do +/// not retain a copy of the output. Writes do not add newlines. The observer +/// does not explicitly flush after writes; call [`Self::flush`] when needed. +#[derive(Debug)] pub struct StdoutObserver { - output: std::io::Cursor>, + output: Output, +} + +#[derive(Debug)] +enum Output { + Buffer(Vec), + Stdout(io::Stdout), + File(File), +} + +impl Default for StdoutObserver { + fn default() -> Self { + Self::buffered() + } } impl StdoutObserver { + /// Captures text in an empty buffer, as does [`Self::default`]. + pub fn buffered() -> Self { + Self { + output: Output::Buffer(Vec::new()), + } + } + + /// Writes text to process stdout without retaining a copy. + pub fn stdout() -> Self { + Self { + output: Output::Stdout(io::stdout()), + } + } + + /// Takes ownership of an open file as the output destination. + /// + /// The caller controls the file's position and open options. Use + /// [`File::create`] to create or truncate a file, or [`std::fs::OpenOptions`] + /// to append. Write errors are reported when text is written. + pub fn file(file: File) -> Self { + Self { + output: Output::File(file), + } + } + + /// Writes the text's UTF-8 bytes unchanged and propagates I/O errors. pub fn write_stdout(&mut self, text: &str) -> Result<()> { - self.output.write_all(text.as_bytes())?; + self.writer().write_all(text.as_bytes())?; Ok(()) } + /// Returns captured bytes, or an empty slice for stdout/file destinations. pub fn output(&self) -> &[u8] { - self.output.get_ref() + match &self.output { + Output::Buffer(buffer) => buffer, + Output::Stdout(_) | Output::File(_) => &[], + } + } + + /// Flushes the destination and propagates I/O errors. + /// + /// This is a no-op for a memory buffer. For a file, this does not call + /// [`File::sync_all`] or guarantee that the bytes have reached disk. + pub fn flush(&mut self) -> Result<()> { + self.writer().flush()?; + Ok(()) + } + + fn writer(&mut self) -> &mut dyn Write { + match &mut self.output { + Output::Buffer(buffer) => buffer, + Output::Stdout(stdout) => stdout, + Output::File(file) => file, + } } } diff --git a/crates/vihaco-stdlib/tests/stdio.rs b/crates/vihaco-stdlib/tests/stdio.rs new file mode 100644 index 00000000..6f12521d --- /dev/null +++ b/crates/vihaco-stdlib/tests/stdio.rs @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2026 The vihaco Authors +// SPDX-License-Identifier: MIT + +use std::fs::{self, File, OpenOptions}; +use std::process::Command; + +use eyre::Result; +use vihaco_runtime::Observe; +use vihaco_stdlib::observer::stdio::{StdoutEffect, StdoutObserver}; + +fn emit_output(observer: &mut StdoutObserver) -> Result<()> { + observer.write_stdout("first: ")?; + for text in ["α", "", "\nlast: 🐦"] { + let follow_ups = observer.observe(&StdoutEffect(text.into()))?; + assert!(follow_ups.into_iter().next().is_none()); + } + observer.flush() +} + +#[test] +fn buffers_preserve_text_and_remain_independent() -> Result<()> { + let mut default = StdoutObserver::default(); + let mut explicit = StdoutObserver::buffered(); + assert!(default.output().is_empty()); + assert!(explicit.output().is_empty()); + + emit_output(&mut default)?; + assert!(explicit.output().is_empty()); + emit_output(&mut explicit)?; + assert_eq!(default.output(), "first: α\nlast: 🐦".as_bytes()); + assert_eq!(default.output(), explicit.output()); + + explicit.write_stdout("!")?; + assert_eq!(default.output(), "first: α\nlast: 🐦".as_bytes()); + assert_eq!(explicit.output(), "first: α\nlast: 🐦!".as_bytes()); + Ok(()) +} + +#[test] +fn file_output_matches_buffer_output() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("stdout.txt"); + let mut file = StdoutObserver::file(File::create(&path)?); + let mut buffer = StdoutObserver::buffered(); + + emit_output(&mut file)?; + emit_output(&mut buffer)?; + assert_eq!(fs::read(path)?, buffer.output()); + assert!(file.output().is_empty()); + Ok(()) +} + +#[test] +fn caller_can_append_to_an_existing_file() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("stdout.txt"); + fs::write(&path, "previous output\n")?; + let file = OpenOptions::new().append(true).open(&path)?; + let mut observer = StdoutObserver::file(file); + + emit_output(&mut observer)?; + assert_eq!( + fs::read_to_string(path)?, + "previous output\nfirst: α\nlast: 🐦" + ); + Ok(()) +} + +#[test] +fn read_only_files_propagate_write_errors() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("stdout.txt"); + fs::write(&path, "original")?; + let mut observer = StdoutObserver::file(File::open(&path)?); + + let error = observer.write_stdout("direct write").unwrap_err(); + assert!(error.downcast_ref::().is_some()); + let error = observer + .observe(&StdoutEffect("observed write".into())) + .unwrap_err(); + assert!(error.downcast_ref::().is_some()); + assert_eq!(fs::read_to_string(path)?, "original"); + Ok(()) +} + +#[test] +fn only_stdout_destination_writes_to_process_stdout() -> Result<()> { + let output = Command::new(std::env::current_exe()?) + .args(["--exact", "stdout_routing_child", "--nocapture"]) + .env("VIHACO_STDOUT_ROUTING_CHILD", "1") + .output()?; + assert!(output.status.success(), "child failed: {output:?}"); + let stdout = String::from_utf8(output.stdout)?; + assert!(stdout.contains("terminal marker: α"), "{stdout}"); + assert!(!stdout.contains("buffer marker: β"), "{stdout}"); + assert!(!stdout.contains("file marker: γ"), "{stdout}"); + Ok(()) +} + +// A subprocess gives this test its own process stdout without changing global state. +#[test] +fn stdout_routing_child() -> Result<()> { + if std::env::var_os("VIHACO_STDOUT_ROUTING_CHILD").is_none() { + return Ok(()); + } + + let mut stdout = StdoutObserver::stdout(); + stdout.observe(&StdoutEffect("terminal marker: α".into()))?; + stdout.flush()?; + assert!(stdout.output().is_empty()); + + for mut buffer in [StdoutObserver::default(), StdoutObserver::buffered()] { + buffer.observe(&StdoutEffect("buffer marker: β".into()))?; + buffer.flush()?; + assert_eq!(buffer.output(), "buffer marker: β".as_bytes()); + } + + let directory = tempfile::tempdir()?; + let path = directory.path().join("stdout.txt"); + let mut file = StdoutObserver::file(File::create(&path)?); + file.observe(&StdoutEffect("file marker: γ".into()))?; + file.flush()?; + assert_eq!(fs::read_to_string(path)?, "file marker: γ"); + assert!(file.output().is_empty()); + Ok(()) +} diff --git a/docs/src/pages/guide/observers.md b/docs/src/pages/guide/observers.md index 8f3d187a..fd048c0e 100644 --- a/docs/src/pages/guide/observers.md +++ b/docs/src/pages/guide/observers.md @@ -107,6 +107,60 @@ impl MultiObserver { The macro generates a separate `Observe` impl for each listed effect type. +## Standard Text Output + +`StdoutObserver` handles the standard `StdoutEffect`. Choose its destination +when constructing it. Each destination uses the same observer type, so a +machine can select one at runtime without changing its field types. + +The default destination captures bytes in memory. `buffered()` selects the +same behavior explicitly: + +```rust +use vihaco::Observe; +use vihaco::observer::stdio::{StdoutEffect, StdoutObserver}; + +let mut output = StdoutObserver::default(); +output.observe(&StdoutEffect("result=1\n".to_owned()))?; +assert_eq!(output.output(), b"result=1\n"); +# Ok::<(), eyre::Report>(()) +``` + +Use `stdout()` to write directly to process stdout: + +```rust,no_run +use vihaco::observer::stdio::StdoutObserver; + +let mut output = StdoutObserver::stdout(); +output.write_stdout("result=1\n")?; +output.flush()?; +# Ok::<(), eyre::Report>(()) +``` + +Use `file()` to transfer ownership of an open file to the observer: + +```rust,no_run +use std::fs::File; +use vihaco::observer::stdio::StdoutObserver; + +let mut output = StdoutObserver::file(File::create("vm-output.txt")?); +output.write_stdout("result=1\n")?; +output.flush()?; +# Ok::<(), eyre::Report>(()) +``` + +`File::create` creates or truncates the file. To append instead, open it with +`OpenOptions::new().create(true).append(true).open(path)` and pass the result +to `file()`. The observer uses the file's current position and open options. + +Both `write_stdout()` and delivered effects write UTF-8 bytes unchanged, +without adding newlines. The observer does not explicitly flush after each +write; process stdout keeps its standard buffering behavior. Call `flush()` +when needed and handle its result, as with writes. Flushing a file does not sync +it to disk. Stdout and file destinations retain no memory copy; +`output()` returns an empty slice for these destinations. Buffer capture is +useful when the caller needs to include VM text in a structured result. + ## When To Declare `effect = ...` An `#[observe(...)]` block defaults to a `()` follow-up effect type. Declare an explicit follow-up type with `effect = ...` once the boundary does typed continuation work instead of a simple `Effects<()>` handoff. In practice, write `effect = CompositeEffect` on the `#[observe(...)]` block when any of these are true: