forge/cmd/
fmt.rs

1use super::watch::WatchArgs;
2use clap::{Parser, ValueHint};
3use eyre::Result;
4use foundry_cli::utils::{FoundryPathExt, LoadConfig};
5use foundry_common::{errors::convert_solar_errors, fs};
6use foundry_compilers::{compilers::solc::SolcLanguage, solc::SOLC_EXTENSIONS};
7use foundry_config::{filter::expand_globs, impl_figment_convert_basic};
8use rayon::prelude::*;
9use similar::{ChangeTag, TextDiff};
10use solar::sema::Compiler;
11use std::{
12    fmt::{self, Write},
13    io,
14    io::Write as _,
15    path::{Path, PathBuf},
16    sync::Arc,
17};
18use yansi::{Color, Paint, Style};
19
20/// CLI arguments for `forge fmt`.
21#[derive(Clone, Debug, Parser)]
22pub struct FmtArgs {
23    /// Path to the file, directory or '-' to read from stdin.
24    #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))]
25    paths: Vec<PathBuf>,
26
27    /// The project's root path.
28    ///
29    /// By default root of the Git repository, if in one,
30    /// or the current working directory.
31    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
32    root: Option<PathBuf>,
33
34    /// Run in 'check' mode.
35    ///
36    /// Exits with 0 if input is formatted correctly.
37    /// Exits with 1 if formatting is required.
38    #[arg(long)]
39    check: bool,
40
41    /// In 'check' and stdin modes, outputs raw formatted code instead of the diff.
42    #[arg(long, short)]
43    raw: bool,
44
45    #[command(flatten)]
46    pub watch: WatchArgs,
47}
48
49impl_figment_convert_basic!(FmtArgs);
50
51impl FmtArgs {
52    pub fn run(self) -> Result<()> {
53        let config = self.load_config()?;
54        let cwd = std::env::current_dir()?;
55
56        // Expand ignore globs and canonicalize from the get go
57        let ignored = expand_globs(&config.root, config.fmt.ignore.iter())?
58            .iter()
59            .flat_map(fs::canonicalize_path)
60            .collect::<Vec<_>>();
61
62        // Expand lib globs separately - we only exclude these during discovery, not explicit paths
63        let libs = expand_globs(&config.root, config.libs.iter().filter_map(|p| p.to_str()))?
64            .iter()
65            .flat_map(fs::canonicalize_path)
66            .collect::<Vec<_>>();
67
68        // Helper to check if a file path is under any ignored or lib directory
69        let is_under_ignored_dir = |file_path: &Path, include_libs: bool| -> bool {
70            let check_against_dir = |dir: &PathBuf| {
71                file_path.starts_with(dir)
72                    || cwd.join(file_path).starts_with(dir)
73                    || fs::canonicalize_path(file_path).is_ok_and(|p| p.starts_with(dir))
74            };
75
76            ignored.iter().any(&check_against_dir)
77                || (include_libs && libs.iter().any(&check_against_dir))
78        };
79
80        let input = match &self.paths[..] {
81            [] => {
82                // Retrieve the project paths, and filter out the ignored ones and libs.
83                let project_paths: Vec<PathBuf> = config
84                    .project_paths::<SolcLanguage>()
85                    .input_files_iter()
86                    .filter(|p| {
87                        !(ignored.contains(p)
88                            || ignored.contains(&cwd.join(p))
89                            || is_under_ignored_dir(p, true))
90                    })
91                    .collect();
92                Input::Paths(project_paths)
93            }
94            [one] if one == Path::new("-") => Input::Stdin,
95            paths => {
96                let mut inputs = Vec::with_capacity(paths.len());
97                for path in paths {
98                    // Check if path is in ignored directories
99                    if !ignored.is_empty()
100                        && ((path.is_absolute() && ignored.contains(path))
101                            || ignored.contains(&cwd.join(path)))
102                    {
103                        continue;
104                    }
105
106                    if path.is_dir() {
107                        // If the input directory is not a lib directory, make sure to ignore libs.
108                        let exclude_libs = !is_under_ignored_dir(path, true);
109                        inputs.extend(
110                            foundry_compilers::utils::source_files_iter(path, SOLC_EXTENSIONS)
111                                .filter(|p| {
112                                    !(ignored.contains(p)
113                                        || ignored.contains(&cwd.join(p))
114                                        || is_under_ignored_dir(p, exclude_libs))
115                                }),
116                        );
117                    } else if path.is_sol() {
118                        // Explicit file paths are always included, even if in a lib
119                        inputs.push(path.to_path_buf());
120                    } else {
121                        warn!("Cannot process path {}", path.display());
122                    }
123                }
124                Input::Paths(inputs)
125            }
126        };
127
128        let mut compiler = Compiler::new(
129            solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(),
130        );
131
132        // Parse, format, and check the diffs.
133        compiler.enter_mut(|compiler| {
134            let mut pcx = compiler.parse();
135            pcx.set_resolve_imports(false);
136            match input {
137                Input::Paths(paths) if paths.is_empty() => {
138                    sh_warn!(
139                        "Nothing to format.\n\
140                         HINT: If you are working outside of the project, \
141                         try providing paths to your source files: `forge fmt <paths>`"
142                    )?;
143                    return Ok(());
144                }
145                Input::Paths(paths) => _ = pcx.par_load_files(paths),
146                Input::Stdin => _ = pcx.load_stdin(),
147            }
148            pcx.parse();
149
150            let gcx = compiler.gcx();
151            let fmt_config = Arc::new(config.fmt);
152            let diffs: Vec<String> = gcx
153                .sources
154                .raw
155                .par_iter()
156                .filter_map(|source_unit| {
157                    let path = source_unit.file.name.as_real();
158                    let original = source_unit.file.src.as_str();
159                    let formatted = forge_fmt::format_ast(gcx, source_unit, fmt_config.clone())?;
160                    let from_stdin = path.is_none();
161
162                    // Return formatted code when read from stdin and raw enabled.
163                    // <https://github.com/foundry-rs/foundry/issues/11871>
164                    if from_stdin && self.raw {
165                        return Some(Ok(formatted));
166                    }
167
168                    if original == formatted {
169                        return None;
170                    }
171
172                    if self.check || from_stdin {
173                        let summary = if self.raw {
174                            formatted
175                        } else {
176                            let name = match path {
177                                Some(path) => path
178                                    .strip_prefix(&config.root)
179                                    .unwrap_or(path)
180                                    .display()
181                                    .to_string(),
182                                None => "stdin".to_string(),
183                            };
184                            format_diff_summary(&name, &TextDiff::from_lines(original, &formatted))
185                        };
186                        Some(Ok(summary))
187                    } else if let Some(path) = path {
188                        match fs::write(path, formatted) {
189                            Ok(()) => {}
190                            Err(e) => return Some(Err(e.into())),
191                        }
192                        let _ = sh_println!("Formatted {}", path.display());
193                        None
194                    } else {
195                        unreachable!()
196                    }
197                })
198                .collect::<Result<_>>()?;
199
200            if !diffs.is_empty() {
201                // This block is only reached in --check mode when files need formatting.
202                let mut stdout = io::stdout().lock();
203                for (i, diff) in diffs.iter().enumerate() {
204                    if i > 0 {
205                        let _ = stdout.write_all(b"\n");
206                    }
207                    let _ = stdout.write_all(diff.as_bytes());
208                }
209                if self.check {
210                    std::process::exit(1);
211                }
212            }
213
214            convert_solar_errors(compiler.dcx())
215        })
216    }
217
218    /// Returns whether `FmtArgs` was configured with `--watch`
219    pub fn is_watch(&self) -> bool {
220        self.watch.watch.is_some()
221    }
222}
223
224#[derive(Debug)]
225enum Input {
226    Stdin,
227    Paths(Vec<PathBuf>),
228}
229
230struct Line(Option<usize>);
231
232impl fmt::Display for Line {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        match self.0 {
235            None => f.write_str("    "),
236            Some(idx) => write!(f, "{:<4}", idx + 1),
237        }
238    }
239}
240
241fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, '_, str>) -> String {
242    let cap = 128;
243    let mut diff_summary = String::with_capacity(cap);
244
245    let _ = writeln!(diff_summary, "Diff in {name}:");
246    for (j, group) in diff.grouped_ops(3).into_iter().enumerate() {
247        if j > 0 {
248            let s =
249                "--------------------------------------------------------------------------------";
250            diff_summary.push_str(s);
251        }
252        for op in group {
253            for change in diff.iter_inline_changes(&op) {
254                let dimmed = Style::new().dim();
255                let (sign, s) = match change.tag() {
256                    ChangeTag::Delete => ("-", Color::Red.foreground()),
257                    ChangeTag::Insert => ("+", Color::Green.foreground()),
258                    ChangeTag::Equal => (" ", dimmed),
259                };
260
261                let _ = write!(
262                    diff_summary,
263                    "{}{} |{}",
264                    Line(change.old_index()).paint(dimmed),
265                    Line(change.new_index()).paint(dimmed),
266                    sign.paint(s.bold()),
267                );
268
269                for (emphasized, value) in change.iter_strings_lossy() {
270                    let s = if emphasized { s.underline().bg(Color::Black) } else { s };
271                    let _ = write!(diff_summary, "{}", value.paint(s));
272                }
273
274                if change.missing_newline() {
275                    diff_summary.push('\n');
276                }
277            }
278        }
279    }
280
281    diff_summary
282}