forge_fmt/
lib.rs

1#![doc = include_str!("../README.md")]
2#![cfg_attr(not(test), warn(unused_crate_dependencies))]
3#![cfg_attr(docsrs, feature(doc_cfg))]
4
5const DEBUG: bool = false || option_env!("FMT_DEBUG").is_some();
6const DEBUG_INDENT: bool = false || option_env!("FMT_DEBUG").is_some();
7
8use foundry_common::comments::{
9    Comment, Comments,
10    inline_config::{InlineConfig, InlineConfigItem},
11};
12
13mod state;
14
15mod pp;
16
17use solar::{
18    parse::{
19        ast::{SourceUnit, Span},
20        interface::{Session, diagnostics::EmittedDiagnostics, source_map::SourceFile},
21    },
22    sema::{Compiler, Gcx, Source},
23};
24
25use std::{path::Path, sync::Arc};
26
27pub use foundry_config::fmt::*;
28
29/// The result of the formatter.
30pub type FormatterResult = DiagnosticsResult<String, EmittedDiagnostics>;
31
32/// The result of the formatter.
33#[derive(Debug)]
34pub enum DiagnosticsResult<T, E> {
35    /// Everything went well.
36    Ok(T),
37    /// No errors encountered, but warnings or other non-error diagnostics were emitted.
38    OkWithDiagnostics(T, E),
39    /// Errors encountered, but a result was produced anyway.
40    ErrRecovered(T, E),
41    /// Fatal errors encountered.
42    Err(E),
43}
44
45impl<T, E> DiagnosticsResult<T, E> {
46    /// Converts the formatter result into a standard result.
47    ///
48    /// This ignores any non-error diagnostics if `Ok`, and any valid result if `Err`.
49    pub fn into_result(self) -> Result<T, E> {
50        match self {
51            Self::Ok(s) | Self::OkWithDiagnostics(s, _) => Ok(s),
52            Self::ErrRecovered(_, d) | Self::Err(d) => Err(d),
53        }
54    }
55
56    /// Returns the result, even if it was produced with errors.
57    pub fn into_ok(self) -> Result<T, E> {
58        match self {
59            Self::Ok(s) | Self::OkWithDiagnostics(s, _) | Self::ErrRecovered(s, _) => Ok(s),
60            Self::Err(e) => Err(e),
61        }
62    }
63
64    /// Returns any result produced.
65    pub fn ok_ref(&self) -> Option<&T> {
66        match self {
67            Self::Ok(s) | Self::OkWithDiagnostics(s, _) | Self::ErrRecovered(s, _) => Some(s),
68            Self::Err(_) => None,
69        }
70    }
71
72    /// Returns any diagnostics emitted.
73    pub fn err_ref(&self) -> Option<&E> {
74        match self {
75            Self::Ok(_) => None,
76            Self::OkWithDiagnostics(_, d) | Self::ErrRecovered(_, d) | Self::Err(d) => Some(d),
77        }
78    }
79
80    /// Returns `true` if the result is `Ok`.
81    pub fn is_ok(&self) -> bool {
82        matches!(self, Self::Ok(_) | Self::OkWithDiagnostics(_, _))
83    }
84
85    /// Returns `true` if the result is `Err`.
86    pub fn is_err(&self) -> bool {
87        !self.is_ok()
88    }
89}
90
91pub fn format_file(
92    path: &Path,
93    config: Arc<FormatterConfig>,
94    compiler: &mut Compiler,
95) -> FormatterResult {
96    format_inner(config, compiler, &|sess| {
97        sess.source_map().load_file(path).map_err(|e| sess.dcx.err(e.to_string()).emit())
98    })
99}
100
101pub fn format_source(
102    source: &str,
103    path: Option<&Path>,
104    config: Arc<FormatterConfig>,
105    compiler: &mut Compiler,
106) -> FormatterResult {
107    format_inner(config, compiler, &|sess| {
108        let name = match path {
109            Some(path) => solar::parse::interface::source_map::FileName::Real(path.to_path_buf()),
110            None => solar::parse::interface::source_map::FileName::Stdin,
111        };
112        sess.source_map()
113            .new_source_file(name, source)
114            .map_err(|e| sess.dcx.err(e.to_string()).emit())
115    })
116}
117
118/// Format a string input with the default compiler.
119pub fn format(source: &str, config: FormatterConfig) -> FormatterResult {
120    let mut compiler = Compiler::new(
121        solar::interface::Session::builder().with_buffer_emitter(Default::default()).build(),
122    );
123
124    format_source(source, None, Arc::new(config), &mut compiler)
125}
126
127fn format_inner(
128    config: Arc<FormatterConfig>,
129    compiler: &mut Compiler,
130    mk_file: &(dyn Fn(&Session) -> solar::parse::interface::Result<Arc<SourceFile>> + Sync + Send),
131) -> FormatterResult {
132    // First pass formatting
133    let first_result = format_once(config.clone(), compiler, mk_file);
134
135    // If first pass was not successful, return the result
136    if first_result.is_err() {
137        return first_result;
138    }
139    let Some(first_formatted) = first_result.ok_ref() else { return first_result };
140
141    // Second pass formatting
142    let second_result = format_once(config, compiler, &|sess| {
143        // Need a new name since we can't overwrite the original file.
144        let prev_sf = mk_file(sess)?;
145        let new_name = match &prev_sf.name {
146            solar::interface::source_map::FileName::Real(path) => {
147                path.with_extension("again.sol").into()
148            }
149            solar::interface::source_map::FileName::Stdin => {
150                solar::interface::source_map::FileName::Custom("stdin-again".to_string())
151            }
152            solar::interface::source_map::FileName::Custom(name) => {
153                solar::interface::source_map::FileName::Custom(format!("{name}-again"))
154            }
155        };
156        sess.source_map()
157            .new_source_file(new_name, first_formatted)
158            .map_err(|e| sess.dcx.err(e.to_string()).emit())
159    });
160
161    // Check if the two passes produce the same output (idempotency)
162    match (first_result.ok_ref(), second_result.ok_ref()) {
163        (Some(first), Some(second)) if first != second => {
164            panic!("formatter is not idempotent:\n{}", diff(first, second));
165        }
166        _ => {}
167    }
168
169    if first_result.is_ok() && second_result.is_err() && !DEBUG {
170        panic!(
171            "failed to format a second time:\nfirst_result={first_result:#?}\nsecond_result={second_result:#?}"
172        );
173        // second_result
174    } else {
175        first_result
176    }
177}
178
179fn diff(first: &str, second: &str) -> impl std::fmt::Display {
180    use std::fmt::Write;
181    let diff = similar::TextDiff::from_lines(first, second);
182    let mut s = String::new();
183    for change in diff.iter_all_changes() {
184        let tag = match change.tag() {
185            similar::ChangeTag::Delete => "-",
186            similar::ChangeTag::Insert => "+",
187            similar::ChangeTag::Equal => " ",
188        };
189        write!(s, "{tag}{change}").unwrap();
190    }
191    s
192}
193
194fn format_once(
195    config: Arc<FormatterConfig>,
196    compiler: &mut Compiler,
197    mk_file: &(
198         dyn Fn(&solar::interface::Session) -> solar::interface::Result<Arc<SourceFile>>
199             + Send
200             + Sync
201     ),
202) -> FormatterResult {
203    let res = compiler.enter_mut(|c| -> solar::interface::Result<String> {
204        let mut pcx = c.parse();
205        pcx.set_resolve_imports(false);
206        let file = mk_file(c.sess())?;
207        pcx.add_file(file.clone());
208        pcx.parse();
209        c.dcx().has_errors()?;
210
211        let gcx = c.gcx();
212        let (_, source) = gcx.get_ast_source(&file.name).unwrap();
213        Ok(format_ast(gcx, source, config).expect("unable to format AST"))
214    });
215
216    let diagnostics = compiler.sess().dcx.emitted_diagnostics().unwrap();
217    match (res, compiler.sess().dcx.has_errors()) {
218        (Ok(s), Ok(())) if diagnostics.is_empty() => FormatterResult::Ok(s),
219        (Ok(s), Ok(())) => FormatterResult::OkWithDiagnostics(s, diagnostics),
220        (Ok(s), Err(_)) => FormatterResult::ErrRecovered(s, diagnostics),
221        (Err(_), Ok(_)) => unreachable!(),
222        (Err(_), Err(_)) => FormatterResult::Err(diagnostics),
223    }
224}
225
226// A parallel-safe "worker" function.
227pub fn format_ast<'ast>(
228    gcx: Gcx<'ast>,
229    source: &'ast Source<'ast>,
230    config: Arc<FormatterConfig>,
231) -> Option<String> {
232    let comments = Comments::new(
233        &source.file,
234        gcx.sess.source_map(),
235        true,
236        config.wrap_comments,
237        if matches!(config.style, IndentStyle::Tab) { Some(config.tab_width) } else { None },
238    );
239    let ast = source.ast.as_ref()?;
240    let inline_config = parse_inline_config(gcx.sess, &comments, ast);
241
242    let mut state = state::State::new(gcx.sess.source_map(), config, inline_config, comments);
243    state.print_source_unit(ast);
244    Some(state.s.eof())
245}
246
247fn parse_inline_config<'ast>(
248    sess: &Session,
249    comments: &Comments,
250    ast: &'ast SourceUnit<'ast>,
251) -> InlineConfig<()> {
252    let parse_item = |mut item: &str, cmnt: &Comment| -> Option<(Span, InlineConfigItem<()>)> {
253        if let Some(prefix) = cmnt.prefix() {
254            item = item.strip_prefix(prefix).unwrap_or(item);
255        }
256        if let Some(suffix) = cmnt.suffix() {
257            item = item.strip_suffix(suffix).unwrap_or(item);
258        }
259        let item = item.trim_start().strip_prefix("forgefmt:")?.trim();
260        match item.parse::<InlineConfigItem<()>>() {
261            Ok(item) => Some((cmnt.span, item)),
262            Err(e) => {
263                sess.dcx.warn(e.to_string()).span(cmnt.span).emit();
264                None
265            }
266        }
267    };
268
269    let items = comments.iter().flat_map(|cmnt| {
270        let mut found_items = Vec::with_capacity(2);
271        // Always process the first line.
272        if let Some(line) = cmnt.lines.first()
273            && let Some(item) = parse_item(line, cmnt)
274        {
275            found_items.push(item);
276        }
277        // If the comment has more than one line, process the last line.
278        if cmnt.lines.len() > 1
279            && let Some(line) = cmnt.lines.last()
280            && let Some(item) = parse_item(line, cmnt)
281        {
282            found_items.push(item);
283        }
284        found_items
285    });
286
287    InlineConfig::from_ast(items, ast, sess.source_map())
288}