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        // Directory walks follow symlinks, so only keep discovered files that resolve inside the
100        // project root or the configured/explicit directory. Explicit file paths are always
101        // included.
102        let root = fs::canonicalize_path(&config.root).unwrap_or_else(|_| config.root.clone());
103        let is_contained = |file_path: &Path, dir: &Path| -> bool {
104            let Ok(resolved) = fs::canonicalize_path(file_path) else { return false };
105            if resolved.starts_with(&root) || resolved.starts_with(dir) {
106                return true;
107            }
108            let _ = sh_warn!(
109                "Skipping {}: it resolves outside of the project root.\n\
110                 HINT: Pass the path explicitly to format it: `forge fmt <paths>`",
111                file_path.display()
112            );
113            false
114        };
115
116        let default_paths = self.paths.is_empty();
117        let paths = if default_paths {
118            let paths = config.project_paths::<SolcLanguage>();
119            // Missing default directories are normal, and configured directories may be external.
120            [paths.sources, paths.tests, paths.scripts]
121                .into_iter()
122                .filter(|path| path.exists())
123                .collect()
124        } else {
125            self.paths
126        };
127        let mut input = match &paths[..] {
128            [one] if one == Path::new("-") => Input::Stdin,
129            paths => {
130                let mut inputs = Vec::with_capacity(paths.len());
131                for path in paths {
132                    // Check if path is in ignored directories
133                    if !self.nearest
134                        && !ignored.is_empty()
135                        && ((path.is_absolute() && ignored.contains(path))
136                            || ignored.contains(&cwd.join(path)))
137                    {
138                        continue;
139                    }
140
141                    if path.is_dir() {
142                        // Only explicitly requested library directories opt in to formatting libs.
143                        let exclude_libs = default_paths || !is_under_dir(path, &libs);
144                        let dir = fs::canonicalize_path(path)?;
145                        inputs.extend(
146                            foundry_compilers::utils::source_files_iter(path, SOLC_EXTENSIONS)
147                                .filter(|p| {
148                                    !((!self.nearest
149                                        && (ignored.contains(p)
150                                            || ignored.contains(&cwd.join(p))
151                                            || is_under_dir(p, &ignored)))
152                                        || (exclude_libs && is_under_dir(p, &libs)))
153                                })
154                                .filter(|p| is_contained(p, &dir)),
155                        );
156                    } else if path.is_sol() {
157                        if default_paths
158                            && ((!self.nearest && is_under_dir(path, &ignored))
159                                || is_under_dir(path, &libs))
160                        {
161                            continue;
162                        }
163                        // CLI file paths are always included, even if in a lib.
164                        inputs.push(path.clone());
165                    } else {
166                        warn!("Cannot process path {}", path.display());
167                    }
168                }
169                Input::Paths(inputs)
170            }
171        };
172
173        let nearest_fmt_configs = if self.nearest {
174            let Input::Paths(paths) = &mut input else {
175                eyre::bail!("`--nearest` cannot be used with stdin");
176            };
177            let mut root_configs: HashMap<PathBuf, (Arc<FormatterConfig>, Vec<PathBuf>)> =
178                HashMap::new();
179            let mut path_configs = HashMap::new();
180            let mut filtered_paths = Vec::with_capacity(paths.len());
181
182            for path in std::mem::take(paths) {
183                let path = fs::canonicalize_path(path)?;
184                let root = find_project_root(path.parent())?;
185                let (fmt_config, ignored) = match root_configs.entry(root) {
186                    Entry::Occupied(entry) => entry.into_mut(),
187                    Entry::Vacant(entry) => {
188                        let nearest_config = Config::load_with_root(entry.key())?.sanitized();
189                        if entry.key() != &config.root {
190                            for warning in &nearest_config.warnings {
191                                let _ = sh_warn!("{warning}");
192                            }
193                        }
194                        let ignored =
195                            expand_globs(&nearest_config.root, nearest_config.fmt.ignore.iter())?
196                                .iter()
197                                .flat_map(fs::canonicalize_path)
198                                .collect();
199                        entry.insert((Arc::new(nearest_config.fmt), ignored))
200                    }
201                };
202                // Both the input path and expanded ignore paths are canonicalized above, so a
203                // component-wise prefix check correctly covers ignored files and directories.
204                if ignored.iter().any(|ignored| path.starts_with(ignored)) {
205                    continue;
206                }
207                path_configs.insert(path.clone(), fmt_config.clone());
208                filtered_paths.push(path);
209            }
210            *paths = filtered_paths;
211            Some(path_configs)
212        } else {
213            None
214        };
215
216        let mut compiler = Compiler::new(
217            solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(),
218        );
219
220        // Parse, format, and check the diffs.
221        compiler.enter_mut(|compiler| {
222            let mut pcx = compiler.parse();
223            pcx.set_resolve_imports(false);
224            match input {
225                Input::Paths(paths) if paths.is_empty() => {
226                    sh_warn!(
227                        "Nothing to format.\n\
228                         HINT: If you are working outside of the project, \
229                         try providing paths to your source files: `forge fmt <paths>`"
230                    )?;
231                    return Ok(());
232                }
233                Input::Paths(paths) => _ = pcx.par_load_files(paths),
234                Input::Stdin => _ = pcx.load_stdin(),
235            }
236            pcx.parse();
237
238            let gcx = compiler.gcx();
239            let fmt_config = Arc::new(config.fmt);
240            let diffs: Vec<String> = gcx
241                .sources
242                .raw
243                .par_iter()
244                .filter_map(|source_unit| {
245                    let path = source_unit.file.name.as_real();
246                    let original = source_unit.file.src.as_str();
247                    let source_fmt_config =
248                        if let Some(nearest_fmt_configs) = nearest_fmt_configs.as_ref() {
249                            let Some(source_path) = path else {
250                                return Some(Err(eyre::eyre!(
251                                    "could not resolve formatter config for stdin"
252                                )));
253                            };
254                            let source_path = match fs::canonicalize_path(source_path) {
255                                Ok(path) => path,
256                                Err(err) => return Some(Err(err.into())),
257                            };
258                            let Some(fmt_config) = nearest_fmt_configs.get(&source_path) else {
259                                return Some(Err(eyre::eyre!(
260                                    "could not resolve formatter config for {}",
261                                    source_path.display()
262                                )));
263                            };
264                            fmt_config.clone()
265                        } else {
266                            fmt_config.clone()
267                        };
268                    let formatted = forge_fmt::format_ast(gcx, source_unit, source_fmt_config)?;
269                    let from_stdin = path.is_none();
270
271                    // Return formatted code when read from stdin and raw enabled.
272                    // <https://github.com/foundry-rs/foundry/issues/11871>
273                    if from_stdin && self.raw {
274                        return Some(Ok(formatted));
275                    }
276
277                    if original == formatted {
278                        return None;
279                    }
280
281                    if self.check || from_stdin {
282                        let summary = if self.raw {
283                            formatted
284                        } else {
285                            let name = match path {
286                                Some(path) => path
287                                    .strip_prefix(&config.root)
288                                    .unwrap_or(path)
289                                    .display()
290                                    .to_string(),
291                                None => "stdin".to_string(),
292                            };
293                            format_diff_summary(&name, &TextDiff::from_lines(original, &formatted))
294                        };
295                        Some(Ok(summary))
296                    } else if let Some(path) = path {
297                        match fs::write(path, formatted) {
298                            Ok(()) => {}
299                            Err(e) => return Some(Err(e.into())),
300                        }
301                        let _ = sh_status!("Formatted {}", path.display());
302                        None
303                    } else {
304                        unreachable!()
305                    }
306                })
307                .collect::<Result<_>>()?;
308
309            if !diffs.is_empty() {
310                // This block is only reached in --check mode when files need formatting.
311                let mut stdout = io::stdout().lock();
312                for (i, diff) in diffs.iter().enumerate() {
313                    if i > 0 {
314                        let _ = stdout.write_all(b"\n");
315                    }
316                    let _ = stdout.write_all(diff.as_bytes());
317                }
318                if self.check {
319                    std::process::exit(1);
320                }
321            }
322
323            convert_solar_errors(compiler.dcx())
324        })
325    }
326
327    /// Returns whether `FmtArgs` was configured with `--watch`
328    pub const fn is_watch(&self) -> bool {
329        self.watch.watch.is_some()
330    }
331}
332
333#[derive(Debug)]
334enum Input {
335    Stdin,
336    Paths(Vec<PathBuf>),
337}
338
339struct Line(Option<usize>);
340
341impl fmt::Display for Line {
342    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
343        match self.0 {
344            None => f.write_str("    "),
345            Some(idx) => write!(f, "{:<4}", idx + 1),
346        }
347    }
348}
349
350fn format_diff_summary<'a>(name: &str, diff: &'a TextDiff<'a, 'a, str>) -> String {
351    let cap = 128;
352    let mut diff_summary = String::with_capacity(cap);
353
354    let _ = writeln!(diff_summary, "Diff in {name}:");
355    for (j, group) in diff.grouped_ops(3).into_iter().enumerate() {
356        if j > 0 {
357            let s =
358                "--------------------------------------------------------------------------------";
359            diff_summary.push_str(s);
360        }
361        for op in group {
362            for change in diff.iter_inline_changes(&op) {
363                let dimmed = Style::new().dim();
364                let (sign, s) = match change.tag() {
365                    ChangeTag::Delete => ("-", Color::Red.foreground()),
366                    ChangeTag::Insert => ("+", Color::Green.foreground()),
367                    ChangeTag::Equal => (" ", dimmed),
368                };
369
370                let _ = write!(
371                    diff_summary,
372                    "{}{} |{}",
373                    Line(change.old_index()).paint(dimmed),
374                    Line(change.new_index()).paint(dimmed),
375                    sign.paint(s.bold()),
376                );
377
378                for (emphasized, value) in change.iter_strings_lossy() {
379                    let s = if emphasized { s.underline().bg(Color::Black) } else { s };
380                    let _ = write!(diff_summary, "{}", value.paint(s));
381                }
382
383                if change.missing_newline() {
384                    diff_summary.push('\n');
385                }
386            }
387        }
388    }
389
390    diff_summary
391}