foundry_common/
term.rs

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