Skip to main content

adam_fmt/
lib.rs

1//! `adam-fmt`'s core: reads one adam-lang `.adm2` file, formats it with
2//! [`adam_lang::format_source`] — the same logic `adam-lsp`'s `textDocument/formatting` handler
3//! uses, so `adam-fmt` and an editor's "Format Document" agree exactly — and writes the result
4//! back in place, only touching the file when its contents actually change.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use std::path::Path;
10//!
11//! let path = Path::new("sheet.adm2");
12//! match adam_fmt::format_file(path) {
13//!     Ok(true) => println!("formatted"),
14//!     Ok(false) => println!("already formatted"),
15//!     Err(error) => eprint!("{}", adam_fmt::render_error(path, &error)),
16//! }
17//! ```
18
19use std::io;
20use std::path::Path;
21
22use adam_lang::FormatSourceError;
23use annotate_snippets::Renderer;
24
25/// Describes an I/O failure or retains the source and diagnostic for refused formatting.
26///
27/// [`format_file`] stores the original source in [`Self::Format`] without changing the file.
28///
29/// # Examples
30///
31/// ```
32/// use adam_fmt::{FormatFileError, render_error};
33/// use std::{io, path::Path};
34///
35/// let error = FormatFileError::Io(io::Error::other("unavailable"));
36/// assert_eq!(
37///     render_error(Path::new("sheet.adm2"), &error),
38///     "adam-fmt: sheet.adm2: unavailable\n"
39/// );
40/// ```
41#[derive(Debug)]
42pub enum FormatFileError {
43    /// `path` could not be read, or (only after formatting succeeds and differs) written back.
44    Io(io::Error),
45    /// `path`'s contents could not be safely formatted.
46    Format {
47        /// `path`'s original contents, needed by [`render_error`] to render a source-anchored
48        /// diagnostic.
49        source: String,
50        /// Why formatting was refused.
51        error: FormatSourceError,
52    },
53}
54
55/// Formats the adam-lang source file at `path` in place.
56///
57/// - Postcondition: on `Ok(true)`, `path`'s contents are replaced with
58///   [`adam_lang::format_source`]'s output. On `Ok(false)`, `path` is left untouched because it
59///   already matched that output exactly (no write performed).
60/// - Complexity: for n input bytes and m output bytes, reads and compares O(n + m)
61///   bytes and writes O(m) bytes when changed. Uses O(n + m) space, in addition to
62///   [`adam_lang::format_source`]'s time and space.
63///
64/// # Errors
65///
66/// Returns [`FormatFileError::Io`] if `path` can't be read, or (only after formatting succeeds
67/// and differs from the original) can't be written back. Returns [`FormatFileError::Format`] if
68/// `path`'s contents can't be safely formatted (see [`adam_lang::format_source`]'s `# Errors`).
69/// A read or format error leaves the file untouched; a write error may leave a partial write.
70///
71/// # Examples
72///
73/// ```no_run
74/// use std::path::Path;
75///
76/// let path = Path::new("sheet.adm2");
77/// let changed = adam_fmt::format_file(path)?;
78/// assert!(!adam_fmt::format_file(path)?);
79/// # Ok::<(), adam_fmt::FormatFileError>(())
80/// ```
81pub fn format_file(path: &Path) -> Result<bool, FormatFileError> {
82    let source = std::fs::read_to_string(path).map_err(FormatFileError::Io)?;
83    match adam_lang::format_source(&source) {
84        Ok(formatted) if formatted == source => Ok(false),
85        Ok(formatted) => {
86            std::fs::write(path, formatted).map_err(FormatFileError::Io)?;
87            Ok(true)
88        }
89        Err(error) => Err(FormatFileError::Format { source, error }),
90    }
91}
92
93/// Renders `error` as a human-readable diagnostic naming `path`, using `rustc`-style source
94/// snippets for a [`FormatFileError::Format`].
95///
96/// I/O diagnostics end with `\n`. Format diagnostics preserve the source renderer's
97/// styling and line endings. Print the returned string directly with `eprint!`.
98///
99/// - Complexity: copies O(p) path bytes and returns O(r) rendered bytes. Additional
100///   time and space follow the source diagnostic renderer for each format error.
101///
102/// # Examples
103///
104/// ```
105/// use adam_fmt::{FormatFileError, render_error};
106/// use std::{io, path::Path};
107///
108/// let error = FormatFileError::Io(io::Error::other("cannot read"));
109/// let diagnostic = render_error(Path::new("sheet.adm2"), &error);
110/// assert_eq!(diagnostic, "adam-fmt: sheet.adm2: cannot read\n");
111/// ```
112#[must_use]
113pub fn render_error(path: &Path, error: &FormatFileError) -> String {
114    match error {
115        FormatFileError::Io(io_error) => format!("adam-fmt: {}: {io_error}\n", path.display()),
116        FormatFileError::Format { source, error } => {
117            let renderer = Renderer::styled();
118            let filename = path.display().to_string();
119            match error {
120                FormatSourceError::Parse(parse_error) => {
121                    parse_error.format_rustc_style(source, &filename, 1, &renderer)
122                }
123                FormatSourceError::Recovered(errors) => errors
124                    .iter()
125                    .map(|e| e.format_rustc_style(source, &filename, 1, &renderer))
126                    .collect(),
127            }
128        }
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    /// Replaces unformatted source with canonical output and reports a change.
137    #[test]
138    fn format_file_rewrites_an_unformatted_file_and_reports_a_change() {
139        let dir = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
140        let path = dir.path().join("sheet.adm2");
141        std::fs::write(&path, "sheet   s{cell x:i32=1;}").unwrap();
142
143        assert!(matches!(format_file(&path), Ok(true)));
144        assert_eq!(
145            std::fs::read_to_string(&path).unwrap(),
146            "sheet s {\n    cell x: i32 = 1;\n}\n"
147        );
148    }
149
150    /// Reports no change for canonical source and preserves its contents and modification time.
151    #[test]
152    fn format_file_leaves_an_already_formatted_file_untouched() {
153        let dir = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
154        let path = dir.path().join("sheet.adm2");
155        let formatted = "sheet s {\n    cell x: i32 = 1;\n}\n";
156        std::fs::write(&path, formatted).unwrap();
157        let modified = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000);
158        std::fs::File::options()
159            .write(true)
160            .open(&path)
161            .unwrap()
162            .set_modified(modified)
163            .unwrap();
164        let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
165
166        assert!(matches!(format_file(&path), Ok(false)));
167        assert_eq!(std::fs::read_to_string(&path).unwrap(), formatted);
168        assert_eq!(
169            std::fs::metadata(&path).unwrap().modified().unwrap(),
170            modified
171        );
172    }
173
174    /// Reports an I/O error for a missing path without creating the file.
175    #[test]
176    fn format_file_errors_on_a_missing_path() {
177        let dir = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
178        let path = dir.path().join("does-not-exist.adm2");
179
180        assert!(matches!(format_file(&path), Err(FormatFileError::Io(_))));
181        assert!(!path.exists());
182    }
183
184    /// Retains unparsable source in the error and leaves the file untouched.
185    #[test]
186    fn format_file_errors_on_unparsable_source_without_writing() {
187        let dir = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
188        let path = dir.path().join("sheet.adm2");
189        std::fs::write(&path, "not a sheet at all").unwrap();
190
191        assert!(matches!(
192            format_file(&path),
193            Err(FormatFileError::Format { source, .. }) if source == "not a sheet at all"
194        ));
195        assert_eq!(
196            std::fs::read_to_string(&path).unwrap(),
197            "not a sheet at all"
198        );
199    }
200
201    /// Rejects non-UTF-8 input as an I/O error without changing its bytes.
202    #[test]
203    fn format_file_errors_on_non_utf8_source_without_writing() {
204        let dir = tempfile::tempdir_in(env!("CARGO_MANIFEST_DIR")).unwrap();
205        let path = dir.path().join("sheet.adm2");
206        std::fs::write(&path, [0xff]).unwrap();
207
208        assert!(matches!(format_file(&path), Err(FormatFileError::Io(_))));
209        assert_eq!(std::fs::read(&path).unwrap(), [0xff]);
210    }
211
212    /// Names the path and I/O failure and terminates the diagnostic with a newline.
213    #[test]
214    fn render_error_reports_the_path_for_an_io_error() {
215        let error = FormatFileError::Io(io::Error::other("boom"));
216        let rendered = render_error(Path::new("missing.adm2"), &error);
217        assert!(rendered.contains("missing.adm2"));
218        assert!(rendered.contains("boom"));
219        assert!(rendered.ends_with('\n'));
220    }
221
222    /// Names the path and shows the rejected source in a parse diagnostic.
223    #[test]
224    fn render_error_reports_a_source_snippet_for_a_format_error() {
225        let source = "not a sheet at all".to_string();
226        let error = adam_lang::format_source(&source).unwrap_err();
227        let rendered = render_error(
228            Path::new("sheet.adm2"),
229            &FormatFileError::Format { source, error },
230        );
231        assert!(rendered.contains("sheet.adm2"));
232        assert!(rendered.contains("not a sheet at all"));
233    }
234
235    /// Includes each recovered diagnostic with its path and source.
236    #[test]
237    fn render_error_reports_a_source_snippet_for_each_recovered_error() {
238        // Exercises FormatSourceError::Recovered specifically (as opposed to ::Parse, covered
239        // above), since that's the variant whose `.collect()` a reviewer once mistakenly flagged
240        // as failing to compile (`String` does implement `FromIterator<String>`).
241        let source = "sheet s { cell x unknown_syntax }".to_string();
242        let error = adam_lang::format_source(&source).unwrap_err();
243        assert!(matches!(error, adam_lang::FormatSourceError::Recovered(_)));
244        let rendered = render_error(
245            Path::new("sheet.adm2"),
246            &FormatFileError::Format { source, error },
247        );
248        assert!(rendered.contains("sheet.adm2"));
249        assert!(rendered.contains("cell x unknown_syntax"));
250    }
251}