forge/cmd/
fmt.rs

1use super::watch::WatchArgs;
2use clap::{Parser, ValueHint};
3use eyre::{Context, Result};
4use forge_fmt::{format_to, parse};
5use foundry_cli::utils::{FoundryPathExt, LoadConfig};
6use foundry_common::fs;
7use foundry_compilers::{compilers::solc::SolcLanguage, solc::SOLC_EXTENSIONS};
8use foundry_config::{filter::expand_globs, impl_figment_convert_basic};
9use rayon::prelude::*;
10use similar::{ChangeTag, TextDiff};
11use std::{
12    fmt::{self, Write},
13    io,
14    io::{Read, Write as _},
15    path::{Path, PathBuf},
16};
17use yansi::{Color, Paint, Style};
18
19/// CLI arguments for `forge fmt`.
20#[derive(Clone, Debug, Parser)]
21pub struct FmtArgs {
22    /// Path to the file, directory or '-' to read from stdin.
23    #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))]
24    paths: Vec<PathBuf>,
25
26    /// The project's root path.
27    ///
28    /// By default root of the Git repository, if in one,
29    /// or the current working directory.
30    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
31    root: Option<PathBuf>,
32
33    /// Run in 'check' mode.
34    ///
35    /// Exits with 0 if input is formatted correctly.
36    /// Exits with 1 if formatting is required.
37    #[arg(long)]
38    check: bool,
39
40    /// In 'check' and stdin modes, outputs raw formatted code instead of the diff.
41    #[arg(long, short)]
42    raw: bool,
43
44    #[command(flatten)]
45    pub watch: WatchArgs,
46}
47
48impl_figment_convert_basic!(FmtArgs);
49
50impl FmtArgs {
51    pub fn run(self) -> Result<()> {
52        let config = self.load_config()?;
53
54        // Expand ignore globs and canonicalize from the get go
55        let ignored = expand_globs(&config.root, config.fmt.ignore.iter())?
56            .iter()
57            .flat_map(foundry_common::fs::canonicalize_path)
58            .collect::<Vec<_>>();
59
60        let cwd = std::env::current_dir()?;
61        let input = match &self.paths[..] {
62            [] => {
63                // Retrieve the project paths, and filter out the ignored ones.
64                let project_paths: Vec<PathBuf> = config
65                    .project_paths::<SolcLanguage>()
66                    .input_files_iter()
67                    .filter(|p| !(ignored.contains(p) || ignored.contains(&cwd.join(p))))
68                    .collect();
69                Input::Paths(project_paths)
70            }
71            [one] if one == Path::new("-") => {
72                let mut s = String::new();
73                io::stdin().read_to_string(&mut s).expect("Failed to read from stdin");
74                Input::Stdin(s)
75            }
76            paths => {
77                let mut inputs = Vec::with_capacity(paths.len());
78                for path in paths {
79                    if !ignored.is_empty() &&
80                        ((path.is_absolute() && ignored.contains(path)) ||
81                            ignored.contains(&cwd.join(path)))
82                    {
83                        continue
84                    }
85
86                    if path.is_dir() {
87                        inputs.extend(foundry_compilers::utils::source_files_iter(
88                            path,
89                            SOLC_EXTENSIONS,
90                        ));
91                    } else if path.is_sol() {
92                        inputs.push(path.to_path_buf());
93                    } else {
94                        warn!("Cannot process path {}", path.display());
95                    }
96                }
97                Input::Paths(inputs)
98            }
99        };
100
101        let format = |source: String, path: Option<&Path>| -> Result<_> {
102            let name = match path {
103                Some(path) => path.strip_prefix(&config.root).unwrap_or(path).display().to_string(),
104                None => "stdin".to_string(),
105            };
106
107            let parsed = parse(&source).wrap_err_with(|| {
108                format!("Failed to parse Solidity code for {name}. Leaving source unchanged.")
109            })?;
110
111            if !parsed.invalid_inline_config_items.is_empty() {
112                for (loc, warning) in &parsed.invalid_inline_config_items {
113                    let mut lines = source[..loc.start().min(source.len())].split('\n');
114                    let col = lines.next_back().unwrap().len() + 1;
115                    let row = lines.count() + 1;
116                    sh_warn!("[{}:{}:{}] {}", name, row, col, warning)?;
117                }
118            }
119
120            let mut output = String::new();
121            format_to(&mut output, parsed, config.fmt.clone()).unwrap();
122
123            solang_parser::parse(&output, 0).map_err(|diags| {
124                eyre::eyre!(
125                    "Failed to construct valid Solidity code for {name}. Leaving source unchanged.\n\
126                     Debug info: {diags:?}\n\
127                     Formatted output:\n\n{output}"
128                )
129            })?;
130
131            let diff = TextDiff::from_lines(&source, &output);
132            let new_format = diff.ratio() < 1.0;
133            if self.check || path.is_none() {
134                if self.raw {
135                    sh_print!("{output}")?;
136                }
137
138                // If new format then compute diff summary.
139                if new_format {
140                    return Ok(Some(format_diff_summary(&name, &diff)))
141                }
142            } else if let Some(path) = path {
143                // If new format then write it on disk.
144                if new_format {
145                    fs::write(path, output)?;
146                }
147            }
148            Ok(None)
149        };
150
151        let diffs = match input {
152            Input::Stdin(source) => format(source, None).map(|diff| vec![diff]),
153            Input::Paths(paths) => {
154                if paths.is_empty() {
155                    sh_warn!(
156                        "Nothing to format.\n\
157                         HINT: If you are working outside of the project, \
158                         try providing paths to your source files: `forge fmt <paths>`"
159                    )?;
160                    return Ok(())
161                }
162                paths
163                    .par_iter()
164                    .map(|path| {
165                        let source = fs::read_to_string(path)?;
166                        format(source, Some(path))
167                    })
168                    .collect()
169            }
170        }?;
171
172        let mut diffs = diffs.iter().flatten();
173        if let Some(first) = diffs.next() {
174            // This branch is only reachable with stdin or --check
175
176            if !self.raw {
177                let mut stdout = io::stdout().lock();
178                let first = std::iter::once(first);
179                for (i, diff) in first.chain(diffs).enumerate() {
180                    if i > 0 {
181                        let _ = stdout.write_all(b"\n");
182                    }
183                    let _ = stdout.write_all(diff.as_bytes());
184                }
185            }
186
187            if self.check {
188                std::process::exit(1);
189            }
190        }
191
192        Ok(())
193    }
194
195    /// Returns whether `FmtArgs` was configured with `--watch`
196    pub fn is_watch(&self) -> bool {
197        self.watch.watch.is_some()
198    }
199}
200
201struct Line(Option<usize>);
202
203#[derive(Debug)]
204enum Input {
205    Stdin(String),
206    Paths(Vec<PathBuf>),
207}
208
209impl fmt::Display for Line {
210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
211        match self.0 {
212            None => f.write_str("    "),
213            Some(idx) => write!(f, "{:<4}", idx + 1),
214        }
215    }
216}
217
218fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, '_, str>) -> String {
219    let cap = 128;
220    let mut diff_summary = String::with_capacity(cap);
221
222    let _ = writeln!(diff_summary, "Diff in {name}:");
223    for (j, group) in diff.grouped_ops(3).into_iter().enumerate() {
224        if j > 0 {
225            let s =
226                "--------------------------------------------------------------------------------";
227            diff_summary.push_str(s);
228        }
229        for op in group {
230            for change in diff.iter_inline_changes(&op) {
231                let dimmed = Style::new().dim();
232                let (sign, s) = match change.tag() {
233                    ChangeTag::Delete => ("-", Color::Red.foreground()),
234                    ChangeTag::Insert => ("+", Color::Green.foreground()),
235                    ChangeTag::Equal => (" ", dimmed),
236                };
237
238                let _ = write!(
239                    diff_summary,
240                    "{}{} |{}",
241                    Line(change.old_index()).paint(dimmed),
242                    Line(change.new_index()).paint(dimmed),
243                    sign.paint(s.bold()),
244                );
245
246                for (emphasized, value) in change.iter_strings_lossy() {
247                    let s = if emphasized { s.underline().bg(Color::Black) } else { s };
248                    let _ = write!(diff_summary, "{}", value.paint(s));
249                }
250
251                if change.missing_newline() {
252                    diff_summary.push('\n');
253                }
254            }
255        }
256    }
257
258    diff_summary
259}