Skip to main content

bekoedit_markdown/
range.rs

1//! Rust-owned UTF-8 byte ranges into canonical Markdown text.
2//!
3//! Per RFC-013 and the requirements definition (§9.7), byte ranges are
4//! always resolved and validated by the Rust core. Ranges originating
5//! from the UI (UTF-16 based editors) are advisory only.
6
7use serde::{Deserialize, Serialize};
8
9/// A half-open byte range `[start, end)` into canonical UTF-8 text.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub struct ByteRange {
12    pub start: usize,
13    pub end: usize,
14}
15
16impl ByteRange {
17    pub fn new(start: usize, end: usize) -> Self {
18        Self { start, end }
19    }
20
21    pub fn len(&self) -> usize {
22        self.end.saturating_sub(self.start)
23    }
24
25    pub fn is_empty(&self) -> bool {
26        self.end <= self.start
27    }
28
29    pub fn contains(&self, other: &ByteRange) -> bool {
30        self.start <= other.start && other.end <= self.end
31    }
32
33    /// Validates that the range is well-formed, inside `text`, and that both
34    /// boundaries lie on UTF-8 character boundaries.
35    ///
36    /// This is the single gate through which every source mutation must pass
37    /// (RFC-015 safety invariant: invalid UTF-8 boundary patches must be
38    /// impossible).
39    pub fn validate(&self, text: &str) -> Result<(), RangeError> {
40        if self.start > self.end {
41            return Err(RangeError::Inverted {
42                start: self.start,
43                end: self.end,
44            });
45        }
46        if self.end > text.len() {
47            return Err(RangeError::OutOfBounds {
48                end: self.end,
49                len: text.len(),
50            });
51        }
52        if !text.is_char_boundary(self.start) {
53            return Err(RangeError::NotCharBoundary { offset: self.start });
54        }
55        if !text.is_char_boundary(self.end) {
56            return Err(RangeError::NotCharBoundary { offset: self.end });
57        }
58        Ok(())
59    }
60
61    /// Returns the slice of `text` covered by this range after validation.
62    pub fn slice<'a>(&self, text: &'a str) -> Result<&'a str, RangeError> {
63        self.validate(text)?;
64        Ok(&text[self.start..self.end])
65    }
66}
67
68/// Converts a UTF-16 code-unit offset (as reported by browser JS APIs such
69/// as `selectionStart` / `selectionEnd`) to a UTF-8 byte position within
70/// `text`.
71///
72/// Returns `None` if `utf16_offset` falls in the middle of a surrogate pair
73/// or exceeds the text's UTF-16 length — both indicate a client-side bug.
74/// Callers must treat `None` as a safe no-op, never a panic.
75pub fn utf16_to_utf8_offset(text: &str, utf16_offset: usize) -> Option<usize> {
76    let mut byte_pos = 0usize;
77    let mut utf16_pos = 0usize;
78    for ch in text.chars() {
79        if utf16_pos == utf16_offset {
80            return Some(byte_pos);
81        }
82        // A surrogate pair spans 2 UTF-16 code units; if the offset lands
83        // inside one, it cannot be a valid boundary — return None.
84        if utf16_pos > utf16_offset {
85            return None;
86        }
87        utf16_pos += ch.len_utf16();
88        byte_pos += ch.len_utf8();
89    }
90    (utf16_pos == utf16_offset).then_some(byte_pos)
91}
92
93impl From<std::ops::Range<usize>> for ByteRange {
94    fn from(r: std::ops::Range<usize>) -> Self {
95        Self::new(r.start, r.end)
96    }
97}
98
99/// Validation failures for byte ranges.
100#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
101pub enum RangeError {
102    #[error("range start {start} is greater than end {end}")]
103    Inverted { start: usize, end: usize },
104    #[error("range end {end} exceeds text length {len}")]
105    OutOfBounds { end: usize, len: usize },
106    #[error("offset {offset} is not a UTF-8 character boundary")]
107    NotCharBoundary { offset: usize },
108}