use super::editorconfig::Resolver;
use super::{Budget, Loaded, external, text};
use crate::bootstrap::Bootstrap;
use crate::fs::{WorkingDir, Writer};
use crate::paths::ProjectPath;
use crate::report::{Code, Diagnostic};
use crate::tree::ReadTree;
struct Change<'a> {
path: &'a ProjectPath,
before: &'a [u8],
after: Vec<u8>,
}
pub fn format(
working: &WorkingDir,
loaded: &[Loaded],
bootstrap: &Bootstrap,
budget: &Budget,
authorized: bool,
diagnostics: &mut Vec<Diagnostic>,
) -> Result<Vec<String>, String> {
let tree: &dyn ReadTree = working;
let assigned = loaded.iter().any(|file| file.selected.formatter.is_some());
if assigned && !authorized {
return Err(format!(
"bearout.toml declares formatters ({}), which run as trusted host programs; pass --allow-formatters (library: `Options::allow_formatters`) to run them",
bootstrap
.formatters
.iter()
.map(|formatter| format!("`{}`", formatter.name))
.collect::<Vec<_>>()
.join(", ")
));
}
let resolver = Resolver::new(tree, budget);
let mut workdirs: Vec<Option<external::Workdir>> = Vec::new();
workdirs.resize_with(bootstrap.formatters.len(), || None);
let mut changes: Vec<Change<'_>> = Vec::new();
for file in loaded {
let path = &file.selected.path;
let effective = match resolver.properties(path) {
Ok(effective) => effective,
Err(problems) => {
diagnostics.extend(problems);
continue;
}
};
let normalized =
match text::normalize(path.as_str(), &file.bytes, file.selected.binary, effective) {
Ok(Some(bytes)) => bytes,
Ok(None) => continue,
Err(diagnostic) => {
diagnostics.push(diagnostic);
continue;
}
};
let after = match file.selected.formatter {
None => normalized,
Some(index) => {
let formatter = &bootstrap.formatters[index];
if workdirs[index].is_none() {
workdirs[index] = Some(external::Workdir::prepare(tree, formatter, budget)?);
}
let workdir = workdirs[index].as_ref().expect("prepared");
match external::run(formatter, workdir, path, &normalized) {
Ok(output) => output,
Err(external::Failure::Start(detail)) => {
return Err(format!(
"formatter `{}` cannot start: {detail}",
formatter.name
));
}
Err(failure) => {
diagnostics.push(Diagnostic::new(
Code::FormatterFailed,
path.as_str(),
format!("formatter `{}` {failure}", formatter.name),
));
continue;
}
}
}
};
if after != file.bytes {
changes.push(Change {
path,
before: &file.bytes,
after,
});
}
}
diagnostics.extend(resolver.take_diagnostics());
resolver.fatal()?;
let before = diagnostics.len();
for change in &changes {
if let Err(diagnostic) = verify(tree, change.path, change.before) {
diagnostics.push(diagnostic);
}
}
if diagnostics.len() > before {
return Ok(Vec::new());
}
let writer = working.writer();
let mut journal: Vec<&Change<'_>> = Vec::new();
let mut failure = None;
for change in &changes {
if let Err(diagnostic) = verify(tree, change.path, change.before) {
failure = Some(diagnostic);
break;
}
if let Err(error) = writer.replace_preserving(change.path, &change.after) {
failure = Some(Diagnostic::new(
Code::FormatWrite,
change.path.as_str(),
format!("cannot write the formatted file: {error}"),
));
break;
}
journal.push(change);
}
match failure {
None => Ok(changes
.iter()
.map(|change| change.path.as_str().to_owned())
.collect()),
Some(failure) => {
diagnostics.push(failure);
restore(tree, &writer, &journal, diagnostics);
Ok(Vec::new())
}
}
}
fn verify(tree: &dyn ReadTree, path: &ProjectPath, expected: &[u8]) -> Result<(), Diagnostic> {
let refuse = |message: String| Diagnostic::new(Code::FormatWrite, path.as_str(), message);
match tree.symlink_component(path) {
Ok(None) => {}
Ok(Some(link)) => {
return Err(refuse(format!(
"`{link}` is a symbolic link; files are never formatted through links"
)));
}
Err(error) => return Err(refuse(format!("cannot inspect the file: {error}"))),
}
match tree.read(path) {
Ok(current) if current == expected => Ok(()),
Ok(_) => Err(refuse(
"file changed after it was read; nothing was written, run again".to_owned(),
)),
Err(error) => Err(refuse(format!("cannot re-read the file: {error}"))),
}
}
fn restore(
tree: &dyn ReadTree,
writer: &Writer<'_>,
journal: &[&Change<'_>],
diagnostics: &mut Vec<Diagnostic>,
) {
let mut restored = 0;
for change in journal.iter().rev() {
let report =
|message: String| Diagnostic::new(Code::FormatWrite, change.path.as_str(), message);
match tree.read(change.path) {
Ok(current) if current == change.after => {
match writer.replace_preserving(change.path, change.before) {
Ok(()) => restored += 1,
Err(error) => diagnostics.push(report(format!(
"could not restore prior content after a failed formatting write: {error}"
))),
}
}
Ok(_) => diagnostics.push(report(
"restoration refused: the file changed after Bearout formatted it, so its current content is kept"
.to_owned(),
)),
Err(error) => diagnostics.push(report(format!(
"could not inspect the file to restore it: {error}"
))),
}
}
if !journal.is_empty() {
diagnostics.push(Diagnostic::new(
Code::FormatWrite,
crate::bootstrap::MANIFEST_NAME,
format!(
"formatting failed after {} change(s); {restored} restored to prior content",
journal.len()
),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rollback_keeps_a_concurrent_edit() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), b"a formatted\n").unwrap();
std::fs::write(dir.path().join("b.txt"), b"b edited meanwhile\n").unwrap();
let working = WorkingDir::open(dir.path()).unwrap();
let a = ProjectPath::parse("a.txt").unwrap();
let b = ProjectPath::parse("b.txt").unwrap();
let changes = [
Change {
path: &a,
before: b"a original\n",
after: b"a formatted\n".to_vec(),
},
Change {
path: &b,
before: b"b original\n",
after: b"b formatted\n".to_vec(),
},
];
let journal: Vec<&Change<'_>> = changes.iter().collect();
let mut diagnostics = Vec::new();
restore(&working, &working.writer(), &journal, &mut diagnostics);
assert_eq!(
std::fs::read(dir.path().join("a.txt")).unwrap(),
b"a original\n"
);
assert_eq!(
std::fs::read(dir.path().join("b.txt")).unwrap(),
b"b edited meanwhile\n",
"the concurrent edit is kept"
);
let rendered: Vec<String> = diagnostics.iter().map(ToString::to_string).collect();
assert_eq!(
rendered,
[
"b.txt:B031: restoration refused: the file changed after Bearout formatted it, so its current content is kept",
"bearout.toml:B031: formatting failed after 2 change(s); 1 restored to prior content",
]
);
}
}