Skip to content

Commit 5ab2c1b

Browse files
feat(common): output channel foundation (#14727)
1 parent 7caa033 commit 5ab2c1b

6 files changed

Lines changed: 361 additions & 17 deletions

File tree

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,17 @@ If you would like to test the binaries built from your change, see [foundryup](h
135135

136136
If you would like to use a debugger with breakpoints to debug a patch you might be working on, keep in mind we currently strip debug info for faster builds, which is _not_ the default. Therefore, to use a debugger, you need to enable it on the workspace [`Cargo.toml`'s `dev` profile](https://github.com/foundry-rs/foundry/tree/HEAD/Cargo.toml#L15-L18).
137137

138+
#### Output channels (stdout vs. stderr)
139+
140+
Foundry CLIs follow a strict output-channel contract: **stdout is the command's
141+
machine-readable result; stderr is everything else** (warnings, errors,
142+
progress, status prose, prompts). When adding or modifying user-facing output,
143+
read [`docs/dev/output-channels.md`](docs/dev/output-channels.md) and use the
144+
`sh_*` macros from `foundry_common::io` (`sh_println!`, `sh_status!`,
145+
`sh_progress!`, `sh_warn!`, `sh_err!`). A workspace-wide clippy
146+
`disallowed-macros` lint (see [`clippy.toml`](clippy.toml)) forbids
147+
`std::print*` and `std::eprint*`; use the `sh_*` macros instead.
148+
138149
#### Adding tests
139150

140151
If the change being proposed alters code, it is either adding new functionality to Foundry, or fixing existing, broken functionality.

crates/common/src/compile.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,8 @@ pub struct ContractInfo {
487487

488488
/// Compiles target file path.
489489
///
490-
/// If `quiet` no solc related output will be emitted to stdout.
490+
/// If `quiet` is set, the compilation reporter's progress/status output is suppressed.
491+
/// (When not suppressed, that output is emitted to stderr; see `with_compilation_reporter`.)
491492
///
492493
/// **Note:** this expects the `target_path` to be absolute
493494
pub fn compile_target<C: Compiler<CompilerContract = Contract>>(
@@ -554,6 +555,11 @@ pub fn etherscan_project(metadata: &Metadata, target_path: &Path) -> Result<Proj
554555
}
555556

556557
/// Configures the reporter and runs the given closure.
558+
///
559+
/// In TTY mode, [`SpinnerReporter`] paints the progress to stderr. The non-TTY fallback
560+
/// still writes to stdout via `BasicStdoutReporter`; migrating that path to stderr is
561+
/// part of the per-command stdout migration tracked in `docs/dev/output-channels.md`
562+
/// (it would shift many existing snapshot tests at once).
557563
pub fn with_compilation_reporter<O>(
558564
quiet: bool,
559565
project_root: Option<PathBuf>,
@@ -563,7 +569,7 @@ pub fn with_compilation_reporter<O>(
563569
let reporter = if quiet || shell::is_json() {
564570
Report::new(NoReporter::default())
565571
} else {
566-
if std::io::stdout().is_terminal() {
572+
if std::io::stderr().is_terminal() {
567573
Report::new(SpinnerReporter::spawn(project_root))
568574
} else {
569575
Report::new(BasicStdoutReporter::default())

crates/common/src/io/macros.rs

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/// Prints a message to [`stdout`][std::io::stdout] and reads a line from stdin into a String.
1+
/// Prints a message to [`stderr`][std::io::stderr] and reads a line from stdin into a String.
22
///
33
/// Returns `Result<T>`, so sometimes `T` must be explicitly specified, like in `str::parse`.
44
///
@@ -20,10 +20,10 @@ macro_rules! prompt {
2020
};
2121

2222
($($tt:tt)+) => {{
23-
let _ = $crate::sh_print!($($tt)+);
24-
match ::std::io::Write::flush(&mut ::std::io::stdout()) {
23+
let _ = $crate::sh_eprint!($($tt)+);
24+
match ::std::io::Write::flush(&mut ::std::io::stderr()) {
2525
::core::result::Result::Ok(()) => $crate::prompt!(),
26-
::core::result::Result::Err(e) => ::core::result::Result::Err(::eyre::eyre!("Could not flush stdout: {e}"))
26+
::core::result::Result::Err(e) => ::core::result::Result::Err(::eyre::eyre!("Could not flush stderr: {e}"))
2727
}
2828
}};
2929
}
@@ -102,6 +102,36 @@ macro_rules! sh_println {
102102
};
103103
}
104104

105+
/// Prints a status message to stderr with a trailing newline.
106+
///
107+
/// Use for human-facing diagnostic prose ("Compiling…", "Deploying contract…")
108+
/// that is not the command's primary machine-readable result.
109+
#[macro_export]
110+
macro_rules! sh_status {
111+
($($args:tt)*) => {
112+
$crate::sh_eprintln!($($args)*)
113+
};
114+
}
115+
116+
/// Prints a progress message to stderr with a trailing newline.
117+
///
118+
/// Use for transient progress updates outside the spinner.
119+
///
120+
/// Suppressed when:
121+
/// - `--quiet` is set, or
122+
/// - stderr is not a tty (e.g. CI logs, piped consumers).
123+
///
124+
/// Always returns `Ok(())`; progress is best-effort and never fails the caller.
125+
#[macro_export]
126+
macro_rules! sh_progress {
127+
($($args:tt)*) => {{
128+
if $crate::shell::is_err_tty() && !$crate::shell::is_quiet() {
129+
let _ = $crate::sh_eprintln!($($args)*);
130+
}
131+
::core::result::Result::<(), ::eyre::Report>::Ok(())
132+
}};
133+
}
134+
105135
/// Prints a raw formatted message to stderr, with a trailing newline.
106136
///
107137
/// **Note**: if `verbosity` is set to `Quiet`, this is a no-op.
@@ -176,6 +206,12 @@ mod tests {
176206
sh_eprintln!("eprintln")?;
177207
sh_eprintln!("eprintln {}", "arg")?;
178208

209+
sh_status!("status")?;
210+
sh_status!("status {}", "arg")?;
211+
212+
sh_progress!("progress")?;
213+
sh_progress!("progress {}", "arg")?;
214+
179215
sh_println!("{:?}", {
180216
sh_println!("hi")?;
181217
solar::data_structures::fmt::from_fn(|f| {
@@ -198,4 +234,64 @@ mod tests {
198234

199235
Ok(())
200236
}
237+
238+
/// Asserts that every macro routes to the channel documented in
239+
/// `docs/dev/output-channels.md`.
240+
#[test]
241+
fn routing_contract() -> eyre::Result<()> {
242+
let mut shell = crate::Shell::captured();
243+
244+
// stdout: machine-readable result
245+
sh_print!(&mut shell, "out-print")?;
246+
sh_println!(&mut shell, "out-println")?;
247+
248+
// stderr: diagnostics + raw stderr
249+
sh_eprint!(&mut shell, "err-print")?;
250+
sh_eprintln!(&mut shell, "err-println")?;
251+
crate::Shell::warn(&mut shell, "warn-msg")?;
252+
crate::Shell::error(&mut shell, "err-msg")?;
253+
254+
let stdout = std::str::from_utf8(shell.captured_stdout().unwrap()).unwrap();
255+
let stderr = std::str::from_utf8(shell.captured_stderr().unwrap()).unwrap();
256+
257+
// stdout only contains what `sh_print!`/`sh_println!` produced.
258+
assert_eq!(stdout, "out-printout-println\n");
259+
260+
// stderr received the eprint/warn/error output and no stdout content.
261+
assert!(stderr.contains("err-print"), "stderr missing eprint: {stderr:?}");
262+
assert!(stderr.contains("err-println"), "stderr missing eprintln: {stderr:?}");
263+
assert!(stderr.contains("warn-msg"), "stderr missing warn: {stderr:?}");
264+
assert!(stderr.contains("err-msg"), "stderr missing error: {stderr:?}");
265+
assert!(!stderr.contains("out-print"), "stdout content leaked to stderr: {stderr:?}");
266+
assert!(!stderr.contains("out-println"), "stdout content leaked to stderr: {stderr:?}");
267+
268+
Ok(())
269+
}
270+
271+
/// `--quiet` currently suppresses both stdout and stderr diagnostics, but `sh_err!` must
272+
/// always be visible. The stdout half of this is intentional for now; it will be flipped
273+
/// to "stdout is never suppressed" once the prose `sh_println!` call sites in forge/script
274+
/// are migrated to `sh_status!` (see `docs/dev/output-channels.md`).
275+
#[test]
276+
fn quiet_contract() -> eyre::Result<()> {
277+
let mut shell = crate::Shell::captured();
278+
shell.set_output_mode(crate::shell::OutputMode::Quiet);
279+
280+
sh_println!(&mut shell, "result")?;
281+
sh_eprintln!(&mut shell, "diag")?;
282+
crate::Shell::warn(&mut shell, "warned")?;
283+
crate::Shell::error(&mut shell, "boom")?;
284+
285+
let stdout = std::str::from_utf8(shell.captured_stdout().unwrap()).unwrap();
286+
let stderr = std::str::from_utf8(shell.captured_stderr().unwrap()).unwrap();
287+
288+
// Today's behavior: stdout is suppressed by --quiet. Pinned here so the future
289+
// migration that flips this bypass has to deliberately update the test.
290+
assert!(stdout.is_empty(), "stdout leaked through --quiet: {stdout:?}");
291+
assert!(!stderr.contains("diag"), "eprintln leaked through --quiet: {stderr:?}");
292+
assert!(!stderr.contains("warned"), "warn leaked through --quiet: {stderr:?}");
293+
assert!(stderr.contains("boom"), "sh_err was suppressed by --quiet: {stderr:?}");
294+
295+
Ok(())
296+
}
201297
}

crates/common/src/io/shell.rs

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ pub fn is_quiet() -> bool {
3838
Shell::get().output_mode().is_quiet()
3939
}
4040

41+
/// Returns whether stderr is a terminal (tty).
42+
///
43+
/// Used to gate progress/spinner output that only makes sense for interactive use.
44+
pub fn is_err_tty() -> bool {
45+
Shell::get().is_err_tty()
46+
}
47+
4148
/// Returns whether the output format is [`OutputFormat::Json`].
4249
pub fn is_json() -> bool {
4350
Shell::get().is_json()
@@ -150,6 +157,8 @@ enum ShellOut {
150157
},
151158
/// A write object that ignores all output.
152159
Empty(std::io::Empty),
160+
/// Captures stdout and stderr into in-memory buffers. Intended for tests.
161+
Captured { stdout: Vec<u8>, stderr: Vec<u8> },
153162
}
154163

155164
/// Whether messages should use color output.
@@ -214,6 +223,37 @@ impl Shell {
214223
}
215224
}
216225

226+
/// Creates a shell that captures stdout and stderr into in-memory buffers.
227+
///
228+
/// Intended for tests that want to assert how a piece of code routes output
229+
/// between stdout and stderr. Use [`Shell::captured_stdout`] and
230+
/// [`Shell::captured_stderr`] to read the buffers back.
231+
pub const fn captured() -> Self {
232+
Self {
233+
output: ShellOut::Captured { stdout: Vec::new(), stderr: Vec::new() },
234+
output_format: OutputFormat::Text,
235+
output_mode: OutputMode::Normal,
236+
verbosity: 0,
237+
needs_clear: AtomicBool::new(false),
238+
}
239+
}
240+
241+
/// Returns the captured stdout buffer, if this shell was created via [`Shell::captured`].
242+
pub fn captured_stdout(&self) -> Option<&[u8]> {
243+
match &self.output {
244+
ShellOut::Captured { stdout, .. } => Some(stdout),
245+
_ => None,
246+
}
247+
}
248+
249+
/// Returns the captured stderr buffer, if this shell was created via [`Shell::captured`].
250+
pub fn captured_stderr(&self) -> Option<&[u8]> {
251+
match &self.output {
252+
ShellOut::Captured { stderr, .. } => Some(stderr),
253+
_ => None,
254+
}
255+
}
256+
217257
/// Acquire a lock to the global shell.
218258
///
219259
/// Initializes it with the default values if it has not been set yet.
@@ -283,38 +323,43 @@ impl Shell {
283323
self.verbosity = verbosity;
284324
}
285325

326+
/// Sets the output mode.
327+
pub const fn set_output_mode(&mut self, output_mode: OutputMode) {
328+
self.output_mode = output_mode;
329+
}
330+
286331
/// Gets the current color choice.
287332
///
288333
/// If we are not using a color stream, this will always return `Never`, even if the color
289334
/// choice has been set to something else.
290335
pub const fn color_choice(&self) -> ColorChoice {
291336
match self.output {
292337
ShellOut::Stream { color_choice, .. } => color_choice,
293-
ShellOut::Empty(_) => ColorChoice::Never,
338+
ShellOut::Empty(_) | ShellOut::Captured { .. } => ColorChoice::Never,
294339
}
295340
}
296341

297342
/// Returns `true` if stderr is a tty.
298343
pub const fn is_err_tty(&self) -> bool {
299344
match self.output {
300345
ShellOut::Stream { stderr_tty, .. } => stderr_tty,
301-
ShellOut::Empty(_) => false,
346+
ShellOut::Empty(_) | ShellOut::Captured { .. } => false,
302347
}
303348
}
304349

305350
/// Whether `stderr` supports color.
306351
pub fn err_supports_color(&self) -> bool {
307352
match &self.output {
308353
ShellOut::Stream { stderr, .. } => supports_color(stderr.current_choice()),
309-
ShellOut::Empty(_) => false,
354+
ShellOut::Empty(_) | ShellOut::Captured { .. } => false,
310355
}
311356
}
312357

313358
/// Whether `stdout` supports color.
314359
pub fn out_supports_color(&self) -> bool {
315360
match &self.output {
316361
ShellOut::Stream { stdout, .. } => supports_color(stdout.current_choice()),
317-
ShellOut::Empty(_) => false,
362+
ShellOut::Empty(_) | ShellOut::Captured { .. } => false,
318363
}
319364
}
320365

@@ -370,6 +415,10 @@ impl Shell {
370415
/// Write a styled fragment with the default color. Use the [`sh_print!`] macro instead.
371416
///
372417
/// **Note**: if `verbosity` is set to `Quiet`, this is a no-op.
418+
//
419+
// TODO: stdout is the canonical machine-readable result of a command and should NOT be
420+
// suppressed by `--quiet` (see `docs/dev/output-channels.md`). Flip this once the major
421+
// prose `sh_println!` call sites in forge/script have been migrated to `sh_status!`.
373422
pub fn print_out(&mut self, fragment: impl fmt::Display) -> Result<()> {
374423
match self.output_mode {
375424
OutputMode::Quiet => Ok(()),
@@ -450,6 +499,7 @@ impl ShellOut {
450499
match self {
451500
Self::Stream { stdout, .. } => stdout,
452501
Self::Empty(e) => e,
502+
Self::Captured { stdout, .. } => stdout,
453503
}
454504
}
455505

@@ -458,6 +508,7 @@ impl ShellOut {
458508
match self {
459509
Self::Stream { stderr, .. } => stderr,
460510
Self::Empty(e) => e,
511+
Self::Captured { stderr, .. } => stderr,
461512
}
462513
}
463514

crates/common/src/term.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@ pub struct TermSettings {
3939

4040
impl TermSettings {
4141
/// Returns a new [`TermSettings`], configured from the current environment.
42+
///
43+
/// Progress is written to stderr (see [`Spinner::tick`]), so it is enabled only
44+
/// when stderr is a terminal.
4245
pub fn from_env() -> Self {
43-
Self { indicate_progress: std::io::stdout().is_terminal() }
46+
Self { indicate_progress: std::io::stderr().is_terminal() }
4447
}
4548
}
4649

@@ -74,8 +77,10 @@ impl Spinner {
7477

7578
let indicator = self.indicator[self.idx % self.indicator.len()].green();
7679
let indicator = Paint::new(format!("[{indicator}]")).bold();
77-
let _ = sh_print!("\r\x1B[2K\r{indicator} {}", self.message);
78-
io::stdout().flush().unwrap();
80+
// Progress is a diagnostic, not data: write to stderr so stdout stays clean
81+
// for machine-readable output.
82+
let _ = sh_eprint!("\r\x1B[2K\r{indicator} {}", self.message);
83+
io::stderr().flush().unwrap();
7984

8085
self.idx = self.idx.wrapping_add(1);
8186
}
@@ -110,17 +115,27 @@ impl SpinnerReporter {
110115
.name("spinner".into())
111116
.spawn(move || {
112117
let mut spinner = Spinner::new("Compiling...");
118+
// Only emit the trailing newline (so past messages aren't overwritten by
119+
// future ticks) when the spinner is actually painting to stderr. When
120+
// `no_progress` is set the spinner is a no-op, so we shouldn't pollute
121+
// stderr with blank lines either.
122+
let emits_progress = !spinner.no_progress;
113123
loop {
114124
spinner.tick();
115125
match rx.try_recv() {
116126
Ok(SpinnerMsg::Msg(msg)) => {
117127
spinner.message(msg);
118-
// new line so past messages are not overwritten
119-
let _ = sh_println!();
128+
if emits_progress {
129+
// new line so past messages are not overwritten
130+
// (matches the spinner channel: stderr)
131+
let _ = sh_eprintln!();
132+
}
120133
}
121134
Ok(SpinnerMsg::Shutdown(ack)) => {
122-
// end with a newline
123-
let _ = sh_println!();
135+
if emits_progress {
136+
// end with a newline (matches the spinner channel: stderr)
137+
let _ = sh_eprintln!();
138+
}
124139
let _ = ack.send(());
125140
break;
126141
}

0 commit comments

Comments
 (0)