#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineEnding {
Lf,
Crlf,
Mixed,
}
#[derive(Debug)]
pub(crate) struct SourceText {
pub(crate) content: String,
ending: LineEnding,
crlf_count: usize,
lf_count: usize,
had_bom: bool,
}
pub(crate) fn decode_for_edit(bytes: &[u8]) -> Result<SourceText, String> {
let text = std::str::from_utf8(bytes).map_err(|_| {
"file is not valid UTF-8; refusing to edit so pre-existing non-UTF-8 bytes aren't \
corrupted into replacement characters. Use bash (sed/awk/perl) for binary or \
legacy-encoded files."
.to_string()
})?;
let (had_bom, text) = match text.strip_prefix('\u{FEFF}') {
Some(rest) => (true, rest),
None => (false, text),
};
let crlf_count = text.matches("\r\n").count();
let total_lf = text.matches('\n').count();
let lf_count = total_lf - crlf_count;
let ending = match (crlf_count, lf_count) {
(0, _) => LineEnding::Lf,
(_, 0) => LineEnding::Crlf,
_ => LineEnding::Mixed,
};
let content = if crlf_count > 0 {
text.replace("\r\n", "\n")
} else {
text.to_string()
};
Ok(SourceText {
content,
ending,
crlf_count,
lf_count,
had_bom,
})
}
pub(crate) struct Reencoded {
pub(crate) text: String,
pub(crate) note: Option<String>,
}
impl SourceText {
pub(crate) fn reencode(&self, new_content: &str) -> Reencoded {
let (body, note) = match self.ending {
LineEnding::Lf => (new_content.to_string(), None),
LineEnding::Crlf => (new_content.replace('\n', "\r\n"), None),
LineEnding::Mixed => {
let crlf_dominant = self.crlf_count >= self.lf_count;
let (body, style) = if crlf_dominant {
(new_content.replace('\n', "\r\n"), "CRLF")
} else {
(new_content.to_string(), "LF")
};
(
body,
Some(format!(
"note: file had mixed line endings ({} CRLF, {} LF); \
normalized to {}.",
self.crlf_count, self.lf_count, style
)),
)
}
};
let text = if self.had_bom {
format!("\u{FEFF}{body}")
} else {
body
};
Reencoded { text, note }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lf_file_round_trips_unchanged() {
let src = decode_for_edit(b"a\nb\nc\n").unwrap();
assert_eq!(src.content, "a\nb\nc\n");
let out = src.reencode(&src.content);
assert_eq!(out.text, "a\nb\nc\n");
assert!(out.note.is_none());
}
#[test]
fn crlf_file_normalizes_for_editing_and_restores_on_write() {
let src = decode_for_edit(b"a\r\nb\r\nc\r\n").unwrap();
assert_eq!(src.content, "a\nb\nc\n");
let out = src.reencode("a\nB\nc\n");
assert_eq!(out.text, "a\r\nB\r\nc\r\n");
assert!(out.note.is_none());
}
#[test]
fn non_utf8_bytes_are_refused() {
let err = decode_for_edit(b"ok\n\xffbad\n").unwrap_err();
assert!(err.contains("not valid UTF-8"), "got: {err}");
}
#[test]
fn leading_bom_is_stripped_then_restored() {
let src = decode_for_edit("\u{FEFF}first\nsecond\n".as_bytes()).unwrap();
assert_eq!(src.content, "first\nsecond\n");
let out = src.reencode("FIRST\nsecond\n");
assert_eq!(out.text, "\u{FEFF}FIRST\nsecond\n");
}
#[test]
fn bom_with_crlf_restores_both() {
let src = decode_for_edit("\u{FEFF}a\r\nb\r\n".as_bytes()).unwrap();
assert_eq!(src.content, "a\nb\n");
let out = src.reencode("a\nb\n");
assert_eq!(out.text, "\u{FEFF}a\r\nb\r\n");
assert!(out.note.is_none());
}
#[test]
fn mixed_endings_normalize_to_dominant_lf_with_note() {
let src = decode_for_edit(b"a\nb\nc\r\nd\n").unwrap();
assert_eq!(src.content, "a\nb\nc\nd\n");
let out = src.reencode("a\nb\nc\nd\n");
assert_eq!(out.text, "a\nb\nc\nd\n");
let note = out.note.expect("mixed file should report normalization");
assert!(note.contains("mixed line endings"), "got: {note}");
assert!(note.contains("LF"), "got: {note}");
}
#[test]
fn mixed_endings_normalize_to_dominant_crlf_with_note() {
let src = decode_for_edit(b"a\r\nb\r\nc\r\nd\n").unwrap();
assert_eq!(src.content, "a\nb\nc\nd\n");
let out = src.reencode("a\nb\nc\nd\n");
assert_eq!(out.text, "a\r\nb\r\nc\r\nd\r\n");
assert!(out.note.unwrap().contains("CRLF"));
}
}