aboutsummaryrefslogtreecommitdiff
path: root/src/buffer/line_ending.rs
diff options
context:
space:
mode:
authorrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
committerrottedfm <rottedfm@proton.me>2026-08-19 11:24:55 -0400
commit8e16347b0eb329e84892af8ece36886324c95f62 (patch)
treebe1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/line_ending.rs
parentc6ae4660d1cc2414b22c492c5e819d009c8187c2 (diff)
parentea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (diff)
Merge branch 'buffer-implementation'HEADmain
Establishes the first working baseline: moji <file> opens a file into a ropey rope and edits it with Helix selection-first modal editing, under a DO-178C DAL-C requirements and traceability process. Prior to this, main tracked four files and src/main.rs was still println!("Hello, world!") — there was no buildable state to build on. Verified on a fresh clone of the branch with no untracked files: cargo build; clippy --all-targets -D warnings clean; 294 tests passing; scripts/check-trace.sh reports 98/98 requirements traced in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/buffer/line_ending.rs')
-rw-r--r--src/buffer/line_ending.rs144
1 files changed, 144 insertions, 0 deletions
diff --git a/src/buffer/line_ending.rs b/src/buffer/line_ending.rs
new file mode 100644
index 0000000..42459de
--- /dev/null
+++ b/src/buffer/line_ending.rs
@@ -0,0 +1,144 @@
+//! Line ending detection and normalisation (MJB-HLR-003).
+//!
+//! The rope stores LF internally regardless of what the file used; the original
+//! terminator is recorded and reapplied on write, so opening and saving a CRLF
+//! file does not silently rewrite every line.
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum LineEnding {
+ #[default]
+ Lf,
+ Crlf,
+ Cr,
+}
+
+impl LineEnding {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ LineEnding::Lf => "\n",
+ LineEnding::Crlf => "\r\n",
+ LineEnding::Cr => "\r",
+ }
+ }
+
+ /// The platform default, used when a buffer contains no terminator at all.
+ pub fn platform_default() -> Self {
+ if cfg!(windows) {
+ LineEnding::Crlf
+ } else {
+ LineEnding::Lf
+ }
+ }
+
+ /// MJB-LLR-114: the line ending of the first terminator present.
+ ///
+ /// Takes `&str` rather than a `RopeSlice`: detection runs on freshly
+ /// decoded text before the rope is built, and accepting a rope would force
+ /// the caller to construct one purely to answer this question.
+ ///
+ /// Scans bytes, not chars. CR and LF are ASCII and cannot appear as a
+ /// continuation byte of a multi-byte sequence, so a byte scan is both
+ /// correct and free of UTF-8 decoding.
+ pub fn detect(text: &str) -> Self {
+ match text.as_bytes().iter().position(|&b| b == b'\n' || b == b'\r') {
+ Some(i) if text.as_bytes()[i] == b'\n' => LineEnding::Lf,
+ // A CR followed by LF is CRLF; a CR followed by anything else, or
+ // by nothing at all, stood alone.
+ Some(i) if text.as_bytes().get(i + 1) == Some(&b'\n') => LineEnding::Crlf,
+ Some(_) => LineEnding::Cr,
+ // MJB-LLR-114: no terminator anywhere.
+ None => Self::platform_default(),
+ }
+ }
+}
+
+/// Rewrite every terminator in `text` (assumed LF-normalised) as `ending`.
+pub fn apply(text: &str, ending: LineEnding) -> String {
+ match ending {
+ LineEnding::Lf => text.to_owned(),
+ LineEnding::Crlf => text.replace('\n', "\r\n"),
+ LineEnding::Cr => text.replace('\n', "\r"),
+ }
+}
+
+/// Normalise CRLF and lone CR to LF for storage in the rope.
+pub fn normalize(text: &str) -> String {
+ if !text.contains('\r') {
+ return text.to_owned();
+ }
+ text.replace("\r\n", "\n").replace('\r', "\n")
+}
+
+/// Append a terminator when `text` is non-empty and lacks one (MJB-LLR-185).
+pub fn with_final_newline(text: &str) -> String {
+ if text.is_empty() || text.ends_with('\n') {
+ text.to_owned()
+ } else {
+ format!("{text}\n")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn detect(s: &str) -> LineEnding {
+ LineEnding::detect(s)
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lf() {
+ assert_eq!(detect("a\nb\n"), LineEnding::Lf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_crlf() {
+ assert_eq!(detect("a\r\nb\r\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn mjb_llr_114_detects_lone_cr() {
+ assert_eq!(detect("a\rb\r"), LineEnding::Cr);
+ }
+
+ #[test]
+ fn mjb_llr_114_falls_back_to_platform_default() {
+ assert_eq!(detect("no terminator"), LineEnding::platform_default());
+ assert_eq!(detect(""), LineEnding::platform_default());
+ }
+
+ #[test]
+ fn mjb_llr_114_first_terminator_decides() {
+ // Mixed endings: the first one wins, as documented.
+ assert_eq!(detect("a\nb\r\n"), LineEnding::Lf);
+ assert_eq!(detect("a\r\nb\n"), LineEnding::Crlf);
+ }
+
+ #[test]
+ fn normalize_collapses_to_lf() {
+ assert_eq!(normalize("a\r\nb\r\n"), "a\nb\n");
+ assert_eq!(normalize("a\rb\r"), "a\nb\n");
+ assert_eq!(normalize("a\nb\n"), "a\nb\n");
+ }
+
+ #[test]
+ fn mjb_llr_115_apply_restores_original_ending() {
+ assert_eq!(apply("a\nb\n", LineEnding::Crlf), "a\r\nb\r\n");
+ assert_eq!(apply("a\nb\n", LineEnding::Cr), "a\rb\r");
+ assert_eq!(apply("a\nb\n", LineEnding::Lf), "a\nb\n");
+ }
+
+ #[test]
+ fn crlf_round_trips_through_normalize_and_apply() {
+ let original = "one\r\ntwo\r\nthree\r\n";
+ let stored = normalize(original);
+ assert_eq!(apply(&stored, LineEnding::Crlf), original);
+ }
+
+ #[test]
+ fn mjb_llr_185_final_newline_only_added_when_missing() {
+ assert_eq!(with_final_newline("a"), "a\n");
+ assert_eq!(with_final_newline("a\n"), "a\n", "not doubled");
+ assert_eq!(with_final_newline(""), "", "empty buffer stays empty");
+ }
+}