Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down
72 changes: 72 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/vihaco-stdlib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ authors.workspace = true
[dependencies]
eyre = { workspace = true }
vihaco-runtime = { workspace = true }

[dev-dependencies]
tempfile = "3"
76 changes: 71 additions & 5 deletions crates/vihaco-stdlib/src/observer/stdio.rs
Original file line number Diff line number Diff line change
@@ -1,27 +1,93 @@
// 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};

#[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<Vec<u8>>,
output: Output,
}

#[derive(Debug)]
enum Output {
Buffer(Vec<u8>),
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,
}
}
}

Expand Down
126 changes: 126 additions & 0 deletions crates/vihaco-stdlib/tests/stdio.rs
Original file line number Diff line number Diff line change
@@ -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::<std::io::Error>().is_some());
let error = observer
.observe(&StdoutEffect("observed write".into()))
.unwrap_err();
assert!(error.downcast_ref::<std::io::Error>().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(())
}
Loading
Loading