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}