diff options
| author | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
|---|---|---|
| committer | rottedfm <rottedfm@proton.me> | 2026-08-19 11:21:47 -0400 |
| commit | ea0bd36167b684c0accdb5ce2b2e21b8d84aeb25 (patch) | |
| tree | be1267972b5de2f1ae592577dfabce67f1fe6e87 /src/buffer/line_ending.rs | |
| parent | 8c0b4c53b130555f040884c1f52b90f16b23e241 (diff) | |
feat: implement Helix-style modal buffer under DO-178C DAL-C
The repository was an unmodified ratatui component template: no editor
code, JSON5 config, and placeholder widgets. This establishes the first
working baseline — `moji <file>` opens a file into a ropey rope and edits
it with Helix selection-first semantics.
Requirements, implementation and tests land together because they must:
the traceability check rejects requirements with no implementation and
tests naming requirements that do not exist, so neither half is a valid
commit on its own.
Package renamed to mojibake-editor (mojibake was taken on crates.io);
binary is moji, library target stays mojibake.
Class: New behaviour
Requirements: MJB-HLR-001..019, MJB-LLR-001..205
Derived: MJB-DR-001..007 (DR-001 resolved, six open for review)
Verified: cargo build; clippy --all-targets -D warnings clean;
cargo test 294 passing; ./scripts/check-trace.sh 98/98/98;
cargo package clean
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.rs | 144 |
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"); + } +} |
