foundry_common/
term.rs

1//! terminal utils
2use foundry_compilers::{
3    artifacts::remappings::Remapping,
4    report::{self, BasicStdoutReporter, Reporter},
5};
6use foundry_config::find_project_root;
7use itertools::Itertools;
8use semver::Version;
9use std::{
10    io,
11    io::{prelude::*, IsTerminal},
12    path::{Path, PathBuf},
13    sync::{
14        mpsc::{self, TryRecvError},
15        LazyLock,
16    },
17    thread,
18    time::Duration,
19};
20use yansi::Paint;
21
22use crate::shell;
23
24/// Some spinners
25// https://github.com/gernest/wow/blob/master/spin/spinners.go
26pub static SPINNERS: &[&[&str]] = &[
27    &["⠃", "⠊", "⠒", "⠢", "⠆", "⠰", "⠔", "⠒", "⠑", "⠘"],
28    &[" ", "⠁", "⠉", "⠙", "⠚", "⠖", "⠦", "⠤", "⠠"],
29    &["┤", "┘", "┴", "└", "├", "┌", "┬", "┐"],
30    &["▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"],
31    &[" ", "▘", "▀", "▜", "█", "▟", "▄", "▖"],
32];
33
34static TERM_SETTINGS: LazyLock<TermSettings> = LazyLock::new(TermSettings::from_env);
35
36/// Helper type to determine the current tty
37pub struct TermSettings {
38    indicate_progress: bool,
39}
40
41impl TermSettings {
42    /// Returns a new [`TermSettings`], configured from the current environment.
43    pub fn from_env() -> Self {
44        Self { indicate_progress: std::io::stdout().is_terminal() }
45    }
46}
47
48#[expect(missing_docs)]
49pub struct Spinner {
50    indicator: &'static [&'static str],
51    no_progress: bool,
52    message: String,
53    idx: usize,
54}
55
56#[expect(missing_docs)]
57impl Spinner {
58    pub fn new(msg: impl Into<String>) -> Self {
59        Self::with_indicator(SPINNERS[0], msg)
60    }
61
62    pub fn with_indicator(indicator: &'static [&'static str], msg: impl Into<String>) -> Self {
63        Self {
64            indicator,
65            no_progress: !TERM_SETTINGS.indicate_progress,
66            message: msg.into(),
67            idx: 0,
68        }
69    }
70
71    pub fn tick(&mut self) {
72        if self.no_progress {
73            return
74        }
75
76        let indicator = self.indicator[self.idx % self.indicator.len()].green();
77        let indicator = Paint::new(format!("[{indicator}]")).bold();
78        let _ = sh_print!("\r\x33[2K\r{indicator} {}", self.message);
79        io::stdout().flush().unwrap();
80
81        self.idx = self.idx.wrapping_add(1);
82    }
83
84    pub fn message(&mut self, msg: impl Into<String>) {
85        self.message = msg.into();
86    }
87}
88
89/// A spinner used as [`report::Reporter`]
90///
91/// This reporter will prefix messages with a spinning cursor
92#[derive(Debug)]
93#[must_use = "Terminates the spinner on drop"]
94pub struct SpinnerReporter {
95    /// The sender to the spinner thread.
96    sender: mpsc::Sender<SpinnerMsg>,
97}
98
99impl SpinnerReporter {
100    /// Spawns the [`Spinner`] on a new thread
101    ///
102    /// The spinner's message will be updated via the `reporter` events
103    ///
104    /// On drop the channel will disconnect and the thread will terminate
105    pub fn spawn() -> Self {
106        let (sender, rx) = mpsc::channel::<SpinnerMsg>();
107
108        std::thread::Builder::new()
109            .name("spinner".into())
110            .spawn(move || {
111                let mut spinner = Spinner::new("Compiling...");
112                loop {
113                    spinner.tick();
114                    match rx.try_recv() {
115                        Ok(SpinnerMsg::Msg(msg)) => {
116                            spinner.message(msg);
117                            // new line so past messages are not overwritten
118                            let _ = sh_println!();
119                        }
120                        Ok(SpinnerMsg::Shutdown(ack)) => {
121                            // end with a newline
122                            let _ = sh_println!();
123                            let _ = ack.send(());
124                            break
125                        }
126                        Err(TryRecvError::Disconnected) => break,
127                        Err(TryRecvError::Empty) => thread::sleep(Duration::from_millis(100)),
128                    }
129                }
130            })
131            .expect("failed to spawn thread");
132
133        Self { sender }
134    }
135
136    fn send_msg(&self, msg: impl Into<String>) {
137        let _ = self.sender.send(SpinnerMsg::Msg(msg.into()));
138    }
139}
140
141enum SpinnerMsg {
142    Msg(String),
143    Shutdown(mpsc::Sender<()>),
144}
145
146impl Drop for SpinnerReporter {
147    fn drop(&mut self) {
148        let (tx, rx) = mpsc::channel();
149        if self.sender.send(SpinnerMsg::Shutdown(tx)).is_ok() {
150            let _ = rx.recv();
151        }
152    }
153}
154
155impl Reporter for SpinnerReporter {
156    fn on_compiler_spawn(&self, compiler_name: &str, version: &Version, dirty_files: &[PathBuf]) {
157        // Verbose message with dirty files displays first to avoid being overlapped
158        // by the spinner in .tick() which prints repeatedly over the same line.
159        if shell::verbosity() >= 5 {
160            let project_root = find_project_root(None);
161
162            self.send_msg(format!(
163                "Files to compile:\n{}",
164                dirty_files
165                    .iter()
166                    .map(|path| {
167                        let trimmed_path = if let Ok(project_root) = &project_root {
168                            path.strip_prefix(project_root).unwrap_or(path)
169                        } else {
170                            path
171                        };
172                        format!("- {}", trimmed_path.display())
173                    })
174                    .sorted()
175                    .format("\n")
176            ));
177        }
178
179        self.send_msg(format!(
180            "Compiling {} files with {} {}.{}.{}",
181            dirty_files.len(),
182            compiler_name,
183            version.major,
184            version.minor,
185            version.patch
186        ));
187    }
188
189    fn on_compiler_success(&self, compiler_name: &str, version: &Version, duration: &Duration) {
190        self.send_msg(format!(
191            "{} {}.{}.{} finished in {duration:.2?}",
192            compiler_name, version.major, version.minor, version.patch
193        ));
194    }
195
196    fn on_solc_installation_start(&self, version: &Version) {
197        self.send_msg(format!("Installing Solc version {version}"));
198    }
199
200    fn on_solc_installation_success(&self, version: &Version) {
201        self.send_msg(format!("Successfully installed Solc {version}"));
202    }
203
204    fn on_solc_installation_error(&self, version: &Version, error: &str) {
205        self.send_msg(format!("Failed to install Solc {version}: {error}").red().to_string());
206    }
207
208    fn on_unresolved_imports(&self, imports: &[(&Path, &Path)], remappings: &[Remapping]) {
209        self.send_msg(report::format_unresolved_imports(imports, remappings));
210    }
211}
212
213/// If the output medium is terminal, this calls `f` within the [`SpinnerReporter`] that displays a
214/// spinning cursor to display solc progress.
215///
216/// If no terminal is available this falls back to common `println!` in [`BasicStdoutReporter`].
217pub fn with_spinner_reporter<T>(f: impl FnOnce() -> T) -> T {
218    let reporter = if TERM_SETTINGS.indicate_progress {
219        report::Report::new(SpinnerReporter::spawn())
220    } else {
221        report::Report::new(BasicStdoutReporter::default())
222    };
223    report::with_scoped(&reporter, f)
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    #[ignore]
232    fn can_spin() {
233        let mut s = Spinner::new("Compiling".to_string());
234        let ticks = 50;
235        for _ in 0..ticks {
236            std::thread::sleep(std::time::Duration::from_millis(100));
237            s.tick();
238        }
239    }
240
241    #[test]
242    fn can_format_properly() {
243        let r = SpinnerReporter::spawn();
244        let remappings: Vec<Remapping> = vec![
245            "library/=library/src/".parse().unwrap(),
246            "weird-erc20/=lib/weird-erc20/src/".parse().unwrap(),
247            "ds-test/=lib/ds-test/src/".parse().unwrap(),
248            "openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/".parse().unwrap(),
249        ];
250        let unresolved = vec![(Path::new("./src/Import.sol"), Path::new("src/File.col"))];
251        r.on_unresolved_imports(&unresolved, &remappings);
252        // formats:
253        // [⠒] Unable to resolve imports:
254        //       "./src/Import.sol" in "src/File.col"
255        // with remappings:
256        //       library/=library/src/
257        //       weird-erc20/=lib/weird-erc20/src/
258        //       ds-test/=lib/ds-test/src/
259        //       openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/
260    }
261}