Skip to main content

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::{
8    Config, filter::expand_globs, find_project_root, fmt::FormatterConfig,
9    impl_figment_convert_basic,
10};
11use rayon::prelude::*;
12use similar::{ChangeTag, TextDiff};
13use solar::sema::Compiler;
14use std::{
15    collections::{HashMap, hash_map::Entry},
16    fmt::{self, Write},
17    io,
18    io::Write as _,
19    path::{Path, PathBuf},
20    sync::Arc,
21};
22use yansi::{Color, Paint, Style};
23
24/// CLI arguments for `forge fmt`.
25#[derive(Clone, Debug, Parser)]
26pub struct FmtArgs {
27    /// Path to the file, directory or '-' to read from stdin.
28    #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))]
29    paths: Vec<PathBuf>,
30
31    /// The project's root path.
32    ///
33    /// By default root of the Git repository, if in one,
34    /// or the current working directory.
35    #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
36    root: Option<PathBuf>,
37
38    /// Use each input file's nearest `foundry.toml` for formatter settings.
39    #[arg(long, conflicts_with = "root")]
40    nearest: bool,
41
42    /// Run in 'check' mode.
43    ///
44    /// Exits with 0 if input is formatted correctly.
45    /// Exits with 1 if formatting is required.
46    #[arg(long)]
47    check: bool,
48
49    /// In 'check' and stdin modes, outputs raw formatted code instead of the diff.
50    #[arg(long, short)]
51    raw: bool,
52
53    #[command(flatten)]
54    pub watch: WatchArgs,
55}
56
57impl_figment_convert_basic!(FmtArgs);
58
59impl FmtArgs {
60    pub fn run(self) -> Result<()> {
61        if self.nearest {
62            for var in ["FOUNDRY_CONFIG", "FOUNDRY_ROOT", "DAPP_ROOT"] {
63                if std::env::var_os(var).is_some() {
64                    eyre::bail!("`--nearest` cannot be used when `{var}` is set");
65                }
66            }
67        }
68        let config = self.load_config()?;
69        let cwd = std::env::current_dir()?;
70
71        // Expand ignore globs and canonicalize from the get go. In nearest mode, ignores are
72        // expanded from the config nearest each input file instead.
73        let ignored = if self.nearest {
74            Vec::new()
75        } else {
76            expand_globs(&config.root, config.fmt.ignore.iter())?
77                .iter()
78                .flat_map(fs::canonicalize_path)
79                .collect::<Vec<_>>()
80        };
81
82        // Expand lib globs separately - we only exclude these during discovery, not explicit paths
83        let libs = expand_globs(&config.root, config.libs.iter().filter_map(|p| p.to_str()))?
84            .iter()
85            .flat_map(fs::canonicalize_path)
86            .collect::<Vec<_>>();
87
88        // Helper to check if a file path is under any of the given directories.
89        let is_under_dir = |file_path: &Path, dirs: &[PathBuf]| -> bool {
90            let check_against_dir = |dir: &PathBuf| {
91                file_path.starts_with(dir)
92                    || cwd.join(file_path).starts_with(dir)
93                    || fs::canonicalize_path(file_path).is_ok_and(|p| p.starts_with(dir))
94            };
95
96            dirs.iter().any(check_against_dir)
97        };
98
99        let mut input = match &self.paths[..] {
100            [] => {
101                // Retrieve the project paths, and filter out the ignored ones and libs.
102                let project_paths: Vec<PathBuf> = config
103                    .project_paths::<SolcLanguage>()
104                    .input_files_iter()
105                    .filter(|p| {
106                        !((!self.nearest
107                            && (ignored.contains(p)
108                                || ignored.contains(&cwd.join(p))
109                                || is_under_dir(p, &ignored)))
110                            || is_under_dir(p, &libs))
111                    })
112                    .collect();
113                Input::Paths(project_paths)
114            }
115            [one] if one == Path::new("-") => Input::Stdin,
116            paths => {
117                let mut inputs = Vec::with_capacity(paths.len());
118                for path in paths {
119                    // Check if path is in ignored directories
120                    if !self.nearest
121                        && !ignored.is_empty()
122                        && ((path.is_absolute() && ignored.contains(path))
123                            || ignored.contains(&cwd.join(path)))
124                    {
125                        continue;
126                    }
127
128                    if path.is_dir() {
129                        // If the input directory is not a lib directory, make sure to ignore libs.
130                        let exclude_libs = !is_under_dir(path, &libs);
131                        inputs.extend(
132                            foundry_compilers::utils::source_files_iter(path, SOLC_EXTENSIONS)
133                                .filter(|p| {
134                                    !((!self.nearest
135                                        && (ignored.contains(p)
136                                            || ignored.contains(&cwd.join(p))
137                                            || is_under_dir(p, &ignored)))
138                                        || (exclude_libs && is_under_dir(p, &libs)))
139                                }),
140                        );
141                    } else if path.is_sol() {
142                        // Explicit file paths are always included, even if in a lib
143                        inputs.push(path.clone());
144                    } else {
145                        warn!("Cannot process path {}", path.display());
146                    }
147                }
148                Input::Paths(inputs)
149            }
150        };
151
152        let nearest_fmt_configs = if self.nearest {
153            let Input::Paths(paths) = &mut input else {
154                eyre::bail!("`--nearest` cannot be used with stdin");
155            };
156            let mut root_configs: HashMap<PathBuf, (Arc<FormatterConfig>, Vec<PathBuf>)> =
157                HashMap::new();
158            let mut path_configs = HashMap::new();
159            let mut filtered_paths = Vec::with_capacity(paths.len());
160
161            for path in std::mem::take(paths) {
162                let path = fs::canonicalize_path(path)?;
163                let root = find_project_root(path.parent())?;
164                let (fmt_config, ignored) = match root_configs.entry(root) {
165                    Entry::Occupied(entry) => entry.into_mut(),
166                    Entry::Vacant(entry) => {
167                        let nearest_config = Config::load_with_root(entry.key())?.sanitized();
168                        if entry.key() != &config.root {
169                            for warning in &nearest_config.warnings {
170                                let _ = sh_warn!("{warning}");
171                            }
172                        }
173                        let ignored =
174                            expand_globs(&nearest_config.root, nearest_config.fmt.ignore.iter())?
175                                .iter()
176                                .flat_map(fs::canonicalize_path)
177                                .collect();
178                        entry.insert((Arc::new(nearest_config.fmt), ignored))
179                    }
180                };
181                // Both the input path and expanded ignore paths are canonicalized above, so a
182                // component-wise prefix check correctly covers ignored files and directories.
183                if ignored.iter().any(|ignored| path.starts_with(ignored)) {
184                    continue;
185                }
186                path_configs.insert(path.clone(), fmt_config.clone());
187                filtered_paths.push(path);
188            }
189            *paths = filtered_paths;
190            Some(path_configs)
191        } else {
192            None
193        };
194
195        let mut compiler = Compiler::new(
196            solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(),
197        );
198
199        // Parse, format, and check the diffs.
200        compiler.enter_mut(|compiler| {
201            let mut pcx = compiler.parse();
202            pcx.set_resolve_imports(false);
203            match input {
204                Input::Paths(paths) if paths.is_empty() => {
205                    sh_warn!(
206                        "Nothing to format.\n\
207                         HINT: If you are working outside of the project, \
208                         try providing paths to your source files: `forge fmt <paths>`"
209                    )?;
210                    return Ok(());
211                }
212                Input::Paths(paths) => _ = pcx.par_load_files(paths),
213                Input::Stdin => _ = pcx.load_stdin(),
214            }
215            pcx.parse();
216
217            let gcx = compiler.gcx();
218            let fmt_config = Arc::new(config.fmt);
219            let diffs: Vec<String> = gcx
220                .sources
221                .raw
222                .par_iter()
223                .filter_map(|source_unit| {
224                    let path = source_unit.file.name.as_real();
225                    let original = source_unit.file.src.as_str();
226                    let source_fmt_config =
227                        if let Some(nearest_fmt_configs) = nearest_fmt_configs.as_ref() {
228                            let Some(source_path) = path else {
229                                return Some(Err(eyre::eyre!(
230                                    "could not resolve formatter config for stdin"
231                                )));
232                            };
233                            let source_path = match fs::canonicalize_path(source_path) {
234                                Ok(path) => path,
235                                Err(err) => return Some(Err(err.into())),
236                            };
237                            let Some(fmt_config) = nearest_fmt_configs.get(&source_path) else {
238                                return Some(Err(eyre::eyre!(
239                                    "could not resolve formatter config for {}",
240                                    source_path.display()
241                                )));
242                            };
243                            fmt_config.clone()
244                        } else {
245                            fmt_config.clone()
246                        };
247                    let formatted = forge_fmt::format_ast(gcx, source_unit, source_fmt_config)?;
248                    let from_stdin = path.is_none();
249
250                    // Return formatted code when read from stdin and raw enabled.
251                    // <https://github.com/foundry-rs/foundry/issues/11871>
252                    if from_stdin && self.raw {
253                        return Some(Ok(formatted));
254                    }
255
256                    if original == formatted {
257                        return None;
258                    }
259
260                    if self.check || from_stdin {
261                        let summary = if self.raw {
262                            formatted
263                        } else {
264                            let name = match path {
265                                Some(path) => path
266                                    .strip_prefix(&config.root)
267                                    .unwrap_or(path)
268                                    .display()
269                                    .to_string(),
270                                None => "stdin".to_string(),
271                            };
272                            format_diff_summary(&name, &TextDiff::from_lines(original, &formatted))
273                        };
274                        Some(Ok(summary))
275                    } else if let Some(path) = path {
276                        match fs::write(path, formatted) {
277                            Ok(()) => {}
278                            Err(e) => return Some(Err(e.into())),
279                        }
280                        let _ = sh_status!("Formatted {}", path.display());
281                        None
282                    } else {
283                        unreachable!()
284                    }
285                })
286                .collect::<Result<_>>()?;
287
288            if !diffs.is_empty() {
289                // This block is only reached in --check mode when files need formatting.
290                let mut stdout = io::stdout().lock();
291                for (i, diff) in diffs.iter().enumerate() {
292                    if i > 0 {
293                        let _ = stdout.write_all(b"\n");
294                    }
295                    let _ = stdout.write_all(diff.as_bytes());
296                }
297                if self.check {
298                    std::process::exit(1);
299                }
300            }
301
302            convert_solar_errors(compiler.dcx())
303        })
304    }
305
306    /// Returns whether `FmtArgs` was configured with `--watch`
307    pub const fn is_watch(&self) -> bool {
308        self.watch.watch.is_some()
309    }
310}
311
312#[derive(Debug)]
313enum Input {
314    Stdin,
315    Paths(Vec<PathBuf>),
316}
317
318struct Line(Option<usize>);
319
320impl fmt::Display for Line {
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        match self.0 {
323            None => f.write_str("    "),
324            Some(idx) => write!(f, "{:<4}", idx + 1),
325        }
326    }
327}
328
329fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, str>) -> String {
330    let cap = 128;
331    let mut diff_summary = String::with_capacity(cap);
332
333    let _ = writeln!(diff_summary, "Diff in {name}:");
334    for (j, group) in diff.grouped_ops(3).into_iter().enumerate() {
335        if j > 0 {
336            let s =
337                "--------------------------------------------------------------------------------";
338            diff_summary.push_str(s);
339        }
340        for op in group {
341            for change in diff.iter_inline_changes(&op) {
342                let dimmed = Style::new().dim();
343                let (sign, s) = match change.tag() {
344                    ChangeTag::Delete => ("-", Color::Red.foreground()),
345                    ChangeTag::Insert => ("+", Color::Green.foreground()),
346                    ChangeTag::Equal => (" ", dimmed),
347                };
348
349                let _ = write!(
350                    diff_summary,
351                    "{}{} |{}",
352                    Line(change.old_index()).paint(dimmed),
353                    Line(change.new_index()).paint(dimmed),
354                    sign.paint(s.bold()),
355                );
356
357                for (emphasized, value) in change.iter_strings_lossy() {
358                    let s = if emphasized { s.underline().bg(Color::Black) } else { s };
359                    let _ = write!(diff_summary, "{}", value.paint(s));
360                }
361
362                if change.missing_newline() {
363                    diff_summary.push('\n');
364                }
365            }
366        }
367    }
368
369    diff_summary
370}