Skip to main content

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 const 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 const 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 const fn is_ok(&self) -> bool {
82        matches!(self, Self::Ok(_) | Self::OkWithDiagnostics(_, _))
83    }
84
85    /// Returns `true` if the result is `Err`.
86    pub const 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    assert!(
170        !first_result.is_ok() || second_result.is_ok() || DEBUG,
171        "failed to format a second time:\nfirst_result={first_result:#?}\nsecond_result={second_result:#?}"
172    );
173    first_result
174}
175
176fn diff(first: &str, second: &str) -> impl std::fmt::Display {
177    use std::fmt::Write;
178    let diff = similar::TextDiff::from_lines(first, second);
179    let mut s = String::new();
180    for change in diff.iter_all_changes() {
181        let tag = match change.tag() {
182            similar::ChangeTag::Delete => "-",
183            similar::ChangeTag::Insert => "+",
184            similar::ChangeTag::Equal => " ",
185        };
186        write!(s, "{tag}{change}").unwrap();
187    }
188    s
189}
190
191fn format_once(
192    config: Arc<FormatterConfig>,
193    compiler: &mut Compiler,
194    mk_file: &(
195         dyn Fn(&solar::interface::Session) -> solar::interface::Result<Arc<SourceFile>>
196             + Send
197             + Sync
198     ),
199) -> FormatterResult {
200    let res = compiler.enter_mut(|c| -> solar::interface::Result<String> {
201        let mut pcx = c.parse();
202        pcx.set_resolve_imports(false);
203        let file = mk_file(c.sess())?;
204        pcx.add_file(file.clone());
205        pcx.parse();
206        c.dcx().has_errors()?;
207
208        let gcx = c.gcx();
209        let (_, source) = gcx.get_ast_source(&file.name).unwrap();
210        Ok(format_ast(gcx, source, config).expect("unable to format AST"))
211    });
212
213    let diagnostics = compiler.sess().dcx.emitted_diagnostics().unwrap();
214    match (res, compiler.sess().dcx.has_errors()) {
215        (Ok(s), Ok(())) if diagnostics.is_empty() => FormatterResult::Ok(s),
216        (Ok(s), Ok(())) => FormatterResult::OkWithDiagnostics(s, diagnostics),
217        (Ok(s), Err(_)) => FormatterResult::ErrRecovered(s, diagnostics),
218        (Err(_), Ok(_)) => unreachable!(),
219        (Err(_), Err(_)) => FormatterResult::Err(diagnostics),
220    }
221}
222
223// A parallel-safe "worker" function.
224pub fn format_ast<'ast>(
225    gcx: Gcx<'ast>,
226    source: &'ast Source<'ast>,
227    config: Arc<FormatterConfig>,
228) -> Option<String> {
229    let comments = Comments::new(
230        &source.file,
231        gcx.sess.source_map(),
232        true,
233        config.wrap_comments,
234        matches!(config.style, IndentStyle::Tab).then(|| config.tab_width),
235    );
236    let ast = source.ast.as_ref()?;
237    let inline_config = parse_inline_config(gcx.sess, &comments, ast);
238
239    let mut state = state::State::new(gcx.sess.source_map(), config, inline_config, comments);
240    state.print_source_unit(ast);
241    Some(state.s.eof())
242}
243
244fn parse_inline_config<'ast>(
245    sess: &Session,
246    comments: &Comments,
247    ast: &'ast SourceUnit<'ast>,
248) -> InlineConfig<()> {
249    let parse_item = |mut item: &str, cmnt: &Comment| -> Option<(Span, InlineConfigItem<()>)> {
250        if let Some(prefix) = cmnt.prefix() {
251            item = item.strip_prefix(prefix).unwrap_or(item);
252        }
253        if let Some(suffix) = cmnt.suffix() {
254            item = item.strip_suffix(suffix).unwrap_or(item);
255        }
256        let item = item.trim_start().strip_prefix("forgefmt:")?.trim();
257        match item.parse::<InlineConfigItem<()>>() {
258            Ok(item) => Some((cmnt.span, item)),
259            Err(e) => {
260                sess.dcx.warn(e.to_string()).span(cmnt.span).emit();
261                None
262            }
263        }
264    };
265
266    let items = comments.iter().flat_map(|cmnt| {
267        let mut found_items = Vec::with_capacity(2);
268        // Always process the first line.
269        if let Some(line) = cmnt.lines.first()
270            && let Some(item) = parse_item(line, cmnt)
271        {
272            found_items.push(item);
273        }
274        // If the comment has more than one line, process the last line.
275        if cmnt.lines.len() > 1
276            && let Some(line) = cmnt.lines.last()
277            && let Some(item) = parse_item(line, cmnt)
278        {
279            found_items.push(item);
280        }
281        found_items
282    });
283
284    InlineConfig::from_ast(items, ast, sess.source_map())
285}