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#[derive(Clone, Debug, Parser)]
26pub struct FmtArgs {
27 #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))]
29 paths: Vec<PathBuf>,
30
31 #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")]
36 root: Option<PathBuf>,
37
38 #[arg(long, conflicts_with = "root")]
40 nearest: bool,
41
42 #[arg(long)]
47 check: bool,
48
49 #[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 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 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 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 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 [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 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 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 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 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 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 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 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 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}