Skip to main content

ephemeris_files/
formatting.rs

1//! Run formatters declared by the evaluated repository state.
2
3use std::collections::BTreeMap;
4use std::process::{Command, ExitStatus};
5use std::{env, fs, io};
6
7use crate::error::IoResultExt as _;
8use crate::path::RepositoryPath;
9use crate::state::FormatterSpec;
10use crate::{FilesError, PathError};
11
12/// A formatter could not be applied to a file.
13#[derive(Debug, thiserror::Error)]
14pub enum FormatterError {
15    /// A partial document declares a per-file formatter override.
16    #[error("per-file formatter overrides are not allowed for partial documents at `{path}`")]
17    PartialOverride {
18        /// The partial document that declared the override.
19        path: RepositoryPath,
20    },
21    /// A formatter spec has no command to run.
22    #[error("empty formatter command for `{path}`")]
23    EmptyCommand {
24        /// The file the empty formatter was declared for.
25        path: RepositoryPath,
26    },
27    /// A formatter's declared project root file is missing from its inputs.
28    #[error("formatter project root file `{project_root_file}` is unavailable for `{path}`")]
29    MissingInput {
30        /// The file being formatted.
31        path: RepositoryPath,
32        /// The missing project root file.
33        project_root_file: RepositoryPath,
34    },
35    /// A formatter command could not be started.
36    #[error("could not run formatter `{command}` for `{path}`")]
37    Spawn {
38        /// The file that was being formatted.
39        path: RepositoryPath,
40        /// The formatter command that failed to start.
41        command: String,
42        /// The underlying spawn failure.
43        #[source]
44        source: io::Error,
45    },
46    /// A formatter command exited unsuccessfully.
47    #[error("formatter `{command}` failed for `{path}` with status {status}: {stderr}")]
48    Failed {
49        /// The file that was being formatted.
50        path: RepositoryPath,
51        /// The formatter command that failed.
52        command: String,
53        /// The formatter process's exit status.
54        status: ExitStatus,
55        /// The formatter process's captured standard error.
56        stderr: String,
57    },
58}
59
60/// Format a complete candidate in an isolated temporary project.
61pub(crate) fn format(
62    path: &RepositoryPath,
63    candidate: &[u8],
64    formatter: &FormatterSpec,
65    assembled_inputs: &BTreeMap<RepositoryPath, Vec<u8>>,
66) -> Result<Vec<u8>, FilesError> {
67    if formatter.command.is_empty() {
68        return Err(FormatterError::EmptyCommand { path: path.clone() }.into());
69    }
70    let temporary = tempfile::tempdir().at_path(env::temp_dir())?;
71    let root = temporary.path();
72    for (input, content) in assembled_inputs {
73        let destination = root.join(input.as_path());
74        let Some(parent) = destination.parent() else {
75            return Err(PathError::Outside(destination).into());
76        };
77        fs::create_dir_all(parent).at_path(parent)?;
78        fs::write(&destination, content).at_path(&destination)?;
79    }
80    let project_root = root.join(formatter.project_root_file.as_path());
81    if !project_root.is_file() {
82        return Err(FormatterError::MissingInput {
83            path: path.clone(),
84            project_root_file: formatter.project_root_file.clone(),
85        }
86        .into());
87    }
88    let target = root.join(path.as_path());
89    if let Some(parent) = target.parent() {
90        fs::create_dir_all(parent).at_path(parent)?;
91    }
92    fs::write(&target, candidate).at_path(&target)?;
93    let mut command = Command::new(&formatter.command);
94    command.args(&formatter.args).arg(path.as_path());
95    let output = command
96        .current_dir(root)
97        .output()
98        .map_err(|source| FormatterError::Spawn {
99            path: path.clone(),
100            command: formatter.command.clone(),
101            source,
102        })?;
103    if !output.status.success() {
104        return Err(FormatterError::Failed {
105            path: path.clone(),
106            command: formatter.command.clone(),
107            status: output.status,
108            stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
109        }
110        .into());
111    }
112    fs::read(&target).at_path(&target)
113}
114
115#[cfg(test)]
116mod tests {
117    use std::collections::BTreeMap;
118
119    use super::format;
120    use crate::path::RepositoryPath;
121    use crate::state::FormatterSpec;
122    use crate::{FilesError, FormatterError};
123
124    #[test]
125    fn formats_candidate_with_planner_assembled_inputs() {
126        let spec = facet_json::from_str::<FormatterSpec>(
127            r#"{"command":"sh","args":["-c","grep -q desired formatter.conf && printf 'bar\\n' > \"$1\"","formatter"],"inputs":["formatter.conf"],"project_root_file":"formatter.conf"}"#,
128        )
129        .unwrap();
130        let inputs = [(
131            RepositoryPath::try_from("formatter.conf").unwrap(),
132            b"desired\n".to_vec(),
133        )]
134        .into();
135        let output = format(
136            &RepositoryPath::try_from("nested/file.txt").unwrap(),
137            b"foo\n",
138            &spec,
139            &inputs,
140        )
141        .expect("formatter succeeds");
142        assert_eq!(output, b"bar\n");
143    }
144
145    #[test]
146    fn formatter_failure_is_an_error() {
147        let spec = facet_json::from_str::<FormatterSpec>(
148            r#"{"command":"sh","args":["-c","exit 7","formatter"],"inputs":[],"project_root_file":"file.txt"}"#,
149        )
150        .unwrap();
151        let path = RepositoryPath::try_from("file.txt").unwrap();
152        let inputs = [(path.clone(), b"root\n".to_vec())].into();
153        assert!(matches!(
154            format(&path, b"content", &spec, &inputs),
155            Err(FilesError::Formatter(FormatterError::Failed { path: failed, command, status, stderr: _ }))
156                if failed == path && command == "sh" && status.code() == Some(7)
157        ));
158    }
159
160    #[test]
161    fn an_empty_formatter_command_is_rejected() {
162        let spec = facet_json::from_str::<FormatterSpec>(
163            r#"{"command":"","args":[],"inputs":[],"project_root_file":"file.txt"}"#,
164        )
165        .unwrap();
166        let path = RepositoryPath::try_from("file.txt").unwrap();
167        assert!(matches!(
168            format(&path, b"content", &spec, &BTreeMap::new()),
169            Err(FilesError::Formatter(FormatterError::EmptyCommand { path: rejected })) if rejected == path
170        ));
171    }
172
173    #[test]
174    fn a_missing_project_root_file_is_rejected() {
175        let spec = facet_json::from_str::<FormatterSpec>(
176            r#"{"command":"true","args":[],"inputs":[],"project_root_file":"flake.nix"}"#,
177        )
178        .unwrap();
179        let path = RepositoryPath::try_from("file.txt").unwrap();
180        assert!(matches!(
181            format(&path, b"content", &spec, &BTreeMap::new()),
182            Err(FilesError::Formatter(FormatterError::MissingInput { path: rejected, project_root_file }))
183                if rejected == path && project_root_file.to_string() == "flake.nix"
184        ));
185    }
186}