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:?}"
127                )
128            })?;
129
130            let diff = TextDiff::from_lines(&source, &output);
131            let new_format = diff.ratio() < 1.0;
132            if self.check || path.is_none() {
133                if self.raw {
134                    sh_print!("{output}")?;
135                }
136
137                // If new format then compute diff summary.
138                if new_format {
139                    return Ok(Some(format_diff_summary(&name, &diff)))
140                }
141            } else if let Some(path) = path {
142                // If new format then write it on disk.
143                if new_format {
144                    fs::write(path, output)?;
145                }
146            }
147            Ok(None)
148        };
149
150        let diffs = match input {
151            Input::Stdin(source) => format(source, None).map(|diff| vec![diff]),
152            Input::Paths(paths) => {
153                if paths.is_empty() {
154                    sh_warn!(
155                        "Nothing to format.\n\
156                         HINT: If you are working outside of the project, \
157                         try providing paths to your source files: `forge fmt <paths>`"
158                    )?;
159                    return Ok(())
160                }
161                paths
162                    .par_iter()
163                    .map(|path| {
164                        let source = fs::read_to_string(path)?;
165                        format(source, Some(path))
166                    })
167                    .collect()
168            }
169        }?;
170
171        let mut diffs = diffs.iter().flatten();
172        if let Some(first) = diffs.next() {
173            // This branch is only reachable with stdin or --check
174
175            if !self.raw {
176                let mut stdout = io::stdout().lock();
177                let first = std::iter::once(first);
178                for (i, diff) in first.chain(diffs).enumerate() {
179                    if i > 0 {
180                        let _ = stdout.write_all(b"\n");
181                    }
182                    let _ = stdout.write_all(diff.as_bytes());
183                }
184            }
185
186            if self.check {
187                std::process::exit(1);
188            }
189        }
190
191        Ok(())
192    }
193
194    /// Returns whether `FmtArgs` was configured with `--watch`
195    pub fn is_watch(&self) -> bool {
196        self.watch.watch.is_some()
197    }
198}
199
200struct Line(Option<usize>);
201
202#[derive(Debug)]
203enum Input {
204    Stdin(String),
205    Paths(Vec<PathBuf>),
206}
207
208impl fmt::Display for Line {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        match self.0 {
211            None => f.write_str("    "),
212            Some(idx) => write!(f, "{:<4}", idx + 1),
213        }
214    }
215}
216
217fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, '_, str>) -> String {
218    let cap = 128;
219    let mut diff_summary = String::with_capacity(cap);
220
221    let _ = writeln!(diff_summary, "Diff in {name}:");
222    for (j, group) in diff.grouped_ops(3).into_iter().enumerate() {
223        if j > 0 {
224            let s =
225                "--------------------------------------------------------------------------------";
226            diff_summary.push_str(s);
227        }
228        for op in group {
229            for change in diff.iter_inline_changes(&op) {
230                let dimmed = Style::new().dim();
231                let (sign, s) = match change.tag() {
232                    ChangeTag::Delete => ("-", Color::Red.foreground()),
233                    ChangeTag::Insert => ("+", Color::Green.foreground()),
234                    ChangeTag::Equal => (" ", dimmed),
235                };
236
237                let _ = write!(
238                    diff_summary,
239                    "{}{} |{}",
240                    Line(change.old_index()).paint(dimmed),
241                    Line(change.new_index()).paint(dimmed),
242                    sign.paint(s.bold()),
243                );
244
245                for (emphasized, value) in change.iter_strings_lossy() {
246                    let s = if emphasized { s.underline().bg(Color::Black) } else { s };
247                    let _ = write!(diff_summary, "{}", value.paint(s));
248                }
249
250                if change.missing_newline() {
251                    diff_summary.push('\n');
252                }
253            }
254        }
255    }
256
257    diff_summary
258}