Skip to main content

foundry_common/
term.rs

1//! terminal utils
2use foundry_compilers::{
3    artifacts::remappings::Remapping,
4    report::{self, 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    ///
43    /// Progress is written to stderr (see [`Spinner::tick`]), so it is enabled only
44    /// when stderr is a terminal.
45    pub fn from_env() -> Self {
46        Self { indicate_progress: std::io::stderr().is_terminal() }
47    }
48}
49
50#[expect(missing_docs)]
51pub struct Spinner {
52    indicator: &'static [&'static str],
53    no_progress: bool,
54    message: String,
55    idx: usize,
56}
57
58#[expect(missing_docs)]
59impl Spinner {
60    pub fn new(msg: impl Into<String>) -> Self {
61        Self::with_indicator(SPINNERS[0], msg)
62    }
63
64    pub fn with_indicator(indicator: &'static [&'static str], msg: impl Into<String>) -> Self {
65        Self {
66            indicator,
67            no_progress: !TERM_SETTINGS.indicate_progress,
68            message: msg.into(),
69            idx: 0,
70        }
71    }
72
73    pub fn tick(&mut self) {
74        if self.no_progress {
75            return;
76        }
77
78        let indicator = self.indicator[self.idx % self.indicator.len()].green();
79        let indicator = Paint::new(format!("[{indicator}]")).bold();
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();
84
85        self.idx = self.idx.wrapping_add(1);
86    }
87
88    pub fn message(&mut self, msg: impl Into<String>) {
89        self.message = msg.into();
90    }
91}
92
93/// A spinner used as [`report::Reporter`]
94///
95/// This reporter will prefix messages with a spinning cursor
96#[derive(Debug)]
97#[must_use = "Terminates the spinner on drop"]
98pub struct SpinnerReporter {
99    /// The sender to the spinner thread.
100    sender: mpsc::Sender<SpinnerMsg>,
101    /// The project root path for trimming file paths in verbose output.
102    project_root: Option<PathBuf>,
103    /// Whether to print the resolved settings for each compiler invocation.
104    print_compiler_settings: bool,
105}
106
107impl SpinnerReporter {
108    /// Spawns the [`Spinner`] on a new thread
109    ///
110    /// The spinner's message will be updated via the `reporter` events
111    ///
112    /// On drop the channel will disconnect and the thread will terminate
113    pub fn spawn(project_root: Option<PathBuf>) -> Self {
114        let (sender, rx) = mpsc::channel::<SpinnerMsg>();
115
116        std::thread::Builder::new()
117            .name("spinner".into())
118            .spawn(move || {
119                let mut spinner = Spinner::new("Compiling...");
120                // Only emit the trailing newline (so past messages aren't overwritten by
121                // future ticks) when the spinner is actually painting to stderr. When
122                // `no_progress` is set the spinner is a no-op, so we shouldn't pollute
123                // stderr with blank lines either.
124                let emits_progress = !spinner.no_progress;
125                loop {
126                    spinner.tick();
127                    match rx.try_recv() {
128                        Ok(SpinnerMsg::Msg(msg)) => {
129                            spinner.message(msg);
130                            if emits_progress {
131                                // new line so past messages are not overwritten
132                                // (matches the spinner channel: stderr)
133                                let _ = sh_eprintln!();
134                            }
135                        }
136                        Ok(SpinnerMsg::Shutdown(ack)) => {
137                            if emits_progress {
138                                // end with a newline (matches the spinner channel: stderr)
139                                let _ = sh_eprintln!();
140                            }
141                            let _ = ack.send(());
142                            break;
143                        }
144                        Err(TryRecvError::Disconnected) => break,
145                        Err(TryRecvError::Empty) => thread::sleep(Duration::from_millis(100)),
146                    }
147                }
148            })
149            .expect("failed to spawn thread");
150
151        Self { sender, project_root, print_compiler_settings: false }
152    }
153
154    /// Sets whether resolved compiler settings are included in progress output.
155    pub const fn with_compiler_settings(mut self, yes: bool) -> Self {
156        self.print_compiler_settings = yes;
157        self
158    }
159
160    fn send_msg(&self, msg: impl Into<String>) {
161        let _ = self.sender.send(SpinnerMsg::Msg(msg.into()));
162    }
163}
164
165enum SpinnerMsg {
166    Msg(String),
167    Shutdown(mpsc::Sender<()>),
168}
169
170impl Drop for SpinnerReporter {
171    fn drop(&mut self) {
172        let (tx, rx) = mpsc::channel();
173        if self.sender.send(SpinnerMsg::Shutdown(tx)).is_ok() {
174            let _ = rx.recv();
175        }
176    }
177}
178
179impl Reporter for SpinnerReporter {
180    fn on_compiler_spawn(&self, compiler_name: &str, version: &Version, dirty_files: &[PathBuf]) {
181        // Verbose message with dirty files displays first to avoid being overlapped
182        // by the spinner in .tick() which prints repeatedly over the same line.
183        if shell::verbosity() >= 5 {
184            self.send_msg(format!(
185                "Files to compile:\n{}",
186                dirty_files
187                    .iter()
188                    .map(|path| {
189                        let trimmed_path = if let Some(project_root) = &self.project_root {
190                            path.strip_prefix(project_root).unwrap_or(path)
191                        } else {
192                            path
193                        };
194                        format!("- {}", trimmed_path.display())
195                    })
196                    .sorted()
197                    .format("\n")
198            ));
199        }
200
201        self.send_msg(format!(
202            "Compiling {} files with {} {}.{}.{}",
203            dirty_files.len(),
204            compiler_name,
205            version.major,
206            version.minor,
207            version.patch
208        ));
209    }
210
211    fn on_compiler_settings(
212        &self,
213        compiler_name: &str,
214        version: &Version,
215        profile: &str,
216        settings: &str,
217    ) {
218        if self.print_compiler_settings {
219            self.send_msg(format!(
220                "Compiler settings for {compiler_name} {}.{}.{} (profile: {profile}): {settings}",
221                version.major, version.minor, version.patch
222            ));
223        }
224    }
225
226    fn on_compiler_success(&self, compiler_name: &str, version: &Version, duration: &Duration) {
227        self.send_msg(format!(
228            "{} {}.{}.{} finished in {duration:.2?}",
229            compiler_name, version.major, version.minor, version.patch
230        ));
231    }
232
233    fn on_solc_installation_start(&self, version: &Version) {
234        self.send_msg(format!("Installing Solc version {version}"));
235    }
236
237    fn on_solc_installation_success(&self, version: &Version) {
238        self.send_msg(format!("Successfully installed Solc {version}"));
239    }
240
241    fn on_solc_installation_error(&self, version: &Version, error: &str) {
242        self.send_msg(format!("Failed to install Solc {version}: {error}").red().to_string());
243    }
244
245    fn on_unresolved_imports(&self, imports: &[(&Path, &Path)], remappings: &[Remapping]) {
246        self.send_msg(report::format_unresolved_imports(imports, remappings));
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    #[ignore]
256    fn can_spin() {
257        let mut s = Spinner::new("Compiling".to_string());
258        let ticks = 50;
259        for _ in 0..ticks {
260            std::thread::sleep(std::time::Duration::from_millis(100));
261            s.tick();
262        }
263    }
264
265    #[test]
266    fn can_format_properly() {
267        let r = SpinnerReporter::spawn(None);
268        let remappings: Vec<Remapping> = vec![
269            "library/=library/src/".parse().unwrap(),
270            "weird-erc20/=lib/weird-erc20/src/".parse().unwrap(),
271            "ds-test/=lib/ds-test/src/".parse().unwrap(),
272            "openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/".parse().unwrap(),
273        ];
274        let unresolved = vec![(Path::new("./src/Import.sol"), Path::new("src/File.col"))];
275        r.on_unresolved_imports(&unresolved, &remappings);
276        // formats:
277        // [⠒] Unable to resolve imports:
278        //       "./src/Import.sol" in "src/File.col"
279        // with remappings:
280        //       library/=library/src/
281        //       weird-erc20/=lib/weird-erc20/src/
282        //       ds-test/=lib/ds-test/src/
283        //       openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/
284    }
285}