Skip to main content

weavatrix_edit/
coordinates.rs

1use crate::{
2    error::{EditError, ErrorCode},
3    limits::LineIndexLimits,
4    model::{Position, PositionEncoding},
5};
6
7use core::mem::size_of;
8
9/// Reusable line index for strict Weavatrix v1 UTF-16 positions.
10#[derive(Clone, Debug)]
11pub struct LineIndex<'text> {
12    text: &'text str,
13    starts: Vec<usize>,
14}
15
16impl<'text> LineIndex<'text> {
17    /// Builds a reusable full line index.
18    ///
19    /// This compatibility constructor retains its infallible signature. Code
20    /// handling untrusted or very large text should use [`Self::try_new`] with
21    /// explicit resource ceilings instead.
22    ///
23    /// # Panics
24    ///
25    /// Panics if the complete line-start table cannot be represented or
26    /// allocated. Use [`Self::try_new`] to receive a bounded error instead.
27    #[must_use]
28    pub fn new(text: &'text str) -> Self {
29        Self::try_new(
30            text,
31            LineIndexLimits {
32                max_lines: usize::MAX,
33                max_index_bytes: usize::MAX,
34            },
35        )
36        .unwrap_or_else(|error| panic!("failed to build line index: {error}"))
37    }
38
39    /// Builds a reusable full line index within explicit resource limits.
40    ///
41    /// The source is borrowed rather than copied. The complete line-start table
42    /// is counted before allocation, checked against both limits, and reserved
43    /// fallibly so hostile newline-heavy input returns an error instead of
44    /// relying on an allocator panic.
45    pub fn try_new(text: &'text str, limits: LineIndexLimits) -> Result<Self, EditError> {
46        let line_count = count_lines(text, limits.max_lines)?;
47        check_index_bytes::<usize>(line_count, limits.max_index_bytes)?;
48
49        let mut starts = Vec::new();
50        starts
51            .try_reserve_exact(line_count)
52            .map_err(|_| index_allocation_error())?;
53        starts.push(0);
54        starts.extend(text.match_indices('\n').map(|(offset, _)| offset + 1));
55        Ok(Self { text, starts })
56    }
57
58    /// Resolves a 1-based line and 0-based UTF-16 code-unit position.
59    ///
60    /// The line feed is excluded from the line length. For compatibility with
61    /// `weavatrix.edit-plan.v1`, a preceding carriage return remains part of a
62    /// CRLF line. Positions inside an astral Unicode scalar fail closed.
63    pub fn byte_offset(&self, position: Position) -> Result<usize, EditError> {
64        self.byte_offset_with_encoding(position, PositionEncoding::Utf16)
65    }
66
67    /// Resolves a strict position using UTF-8, UTF-16, or UTF-32 character units.
68    pub fn byte_offset_with_encoding(
69        &self,
70        position: Position,
71        encoding: PositionEncoding,
72    ) -> Result<usize, EditError> {
73        if position.line == 0 {
74            return Err(position_error(position, "line numbers are 1-based"));
75        }
76        let line_index = usize::try_from(position.line - 1)
77            .map_err(|_| position_error(position, "line number is too large"))?;
78        let Some(&start) = self.starts.get(line_index) else {
79            return Err(position_error(position, "line exceeds file line count"));
80        };
81        let end = self
82            .starts
83            .get(line_index + 1)
84            .map_or(self.text.len(), |next| next - 1);
85        resolve_character(&self.text[start..end], start, position, encoding)
86    }
87
88    /// Maps a UTF-8 byte boundary back to a strict line/character position.
89    pub fn position_at(
90        &self,
91        byte_offset: usize,
92        encoding: PositionEncoding,
93    ) -> Result<Position, EditError> {
94        if byte_offset > self.text.len() || !self.text.is_char_boundary(byte_offset) {
95            return Err(EditError::new(
96                ErrorCode::PositionOutOfRange,
97                "byte offset is outside the text or splits a Unicode scalar value",
98            ));
99        }
100        let line_index = self.starts.partition_point(|start| *start <= byte_offset) - 1;
101        let start = self.starts[line_index];
102        let units = count_units(&self.text[start..byte_offset], encoding);
103        Ok(Position::new(
104            u32::try_from(line_index + 1).map_err(|_| {
105                EditError::new(ErrorCode::PositionOutOfRange, "line number exceeds u32")
106            })?,
107            u32::try_from(units).map_err(|_| {
108                EditError::new(
109                    ErrorCode::PositionOutOfRange,
110                    "character offset exceeds u32",
111                )
112            })?,
113        ))
114    }
115
116    #[must_use]
117    pub fn line_count(&self) -> usize {
118        self.starts.len()
119    }
120}
121
122#[derive(Clone, Copy, Debug)]
123pub(crate) struct SparseLine {
124    line: u32,
125    start: usize,
126    end: usize,
127    found: bool,
128}
129
130/// A one-shot position resolver whose allocation is proportional to requested
131/// edit lines rather than the source's total line count.
132pub(crate) struct SparseLineIndex<'text> {
133    text: &'text str,
134    lines: Vec<SparseLine>,
135}
136
137impl<'text> SparseLineIndex<'text> {
138    pub(crate) fn try_for_line_pairs(
139        text: &'text str,
140        requested_line_pairs: impl ExactSizeIterator<Item = (u32, u32)>,
141    ) -> Result<Self, EditError> {
142        let requested_capacity = requested_line_pairs
143            .len()
144            .checked_mul(2)
145            .ok_or_else(index_byte_limit_error)?;
146        let max_index_bytes = requested_capacity
147            .checked_mul(size_of::<SparseLine>())
148            .ok_or_else(index_byte_limit_error)?;
149        check_index_bytes::<SparseLine>(requested_capacity, max_index_bytes)?;
150        let max_lines = text.len().checked_add(1).ok_or_else(line_limit_error)?;
151
152        let mut lines = Vec::new();
153        lines
154            .try_reserve_exact(requested_capacity)
155            .map_err(|_| index_allocation_error())?;
156        for (start_line, end_line) in requested_line_pairs {
157            for line in [start_line, end_line] {
158                lines.push(SparseLine {
159                    line,
160                    start: 0,
161                    end: 0,
162                    found: false,
163                });
164            }
165        }
166        lines.sort_unstable_by_key(|entry| entry.line);
167        lines.dedup_by_key(|entry| entry.line);
168
169        let mut current_line = 1_usize;
170        if current_line > max_lines {
171            return Err(line_limit_error());
172        }
173        let mut line_start = 0_usize;
174        let mut requested = 0_usize;
175
176        for (offset, byte) in text.bytes().enumerate() {
177            if byte != b'\n' {
178                continue;
179            }
180            fill_sparse_line(&mut lines, &mut requested, current_line, line_start, offset);
181            current_line = current_line.checked_add(1).ok_or_else(line_limit_error)?;
182            if current_line > max_lines {
183                return Err(line_limit_error());
184            }
185            line_start = offset + 1;
186        }
187        fill_sparse_line(
188            &mut lines,
189            &mut requested,
190            current_line,
191            line_start,
192            text.len(),
193        );
194
195        Ok(Self { text, lines })
196    }
197
198    pub(crate) fn byte_offset_with_encoding(
199        &self,
200        position: Position,
201        encoding: PositionEncoding,
202    ) -> Result<usize, EditError> {
203        if position.line == 0 {
204            return Err(position_error(position, "line numbers are 1-based"));
205        }
206        let Ok(index) = self
207            .lines
208            .binary_search_by_key(&position.line, |entry| entry.line)
209        else {
210            return Err(position_error(position, "line was not indexed"));
211        };
212        let line = self.lines[index];
213        if !line.found {
214            return Err(position_error(position, "line exceeds file line count"));
215        }
216        resolve_character(
217            &self.text[line.start..line.end],
218            line.start,
219            position,
220            encoding,
221        )
222    }
223}
224
225fn fill_sparse_line(
226    lines: &mut [SparseLine],
227    requested: &mut usize,
228    current_line: usize,
229    start: usize,
230    end: usize,
231) {
232    while let Some(entry) = lines.get_mut(*requested) {
233        let Ok(requested_line) = usize::try_from(entry.line) else {
234            break;
235        };
236        if requested_line > current_line {
237            break;
238        }
239        if requested_line == current_line {
240            entry.start = start;
241            entry.end = end;
242            entry.found = true;
243        }
244        *requested += 1;
245    }
246}
247
248fn count_lines(text: &str, max_lines: usize) -> Result<usize, EditError> {
249    let mut lines = 1_usize;
250    if lines > max_lines {
251        return Err(line_limit_error());
252    }
253    for byte in text.bytes() {
254        if byte == b'\n' {
255            lines = lines.checked_add(1).ok_or_else(line_limit_error)?;
256            if lines > max_lines {
257                return Err(line_limit_error());
258            }
259        }
260    }
261    Ok(lines)
262}
263
264fn check_index_bytes<Entry>(entries: usize, max_index_bytes: usize) -> Result<(), EditError> {
265    let Some(index_bytes) = entries.checked_mul(size_of::<Entry>()) else {
266        return Err(index_byte_limit_error());
267    };
268    if index_bytes > max_index_bytes {
269        return Err(index_byte_limit_error());
270    }
271    Ok(())
272}
273
274fn line_limit_error() -> EditError {
275    EditError::new(ErrorCode::PlanTooLarge, "line count exceeds index limits")
276}
277
278fn index_byte_limit_error() -> EditError {
279    EditError::new(ErrorCode::PlanTooLarge, "line index exceeds its byte limit")
280}
281
282fn index_allocation_error() -> EditError {
283    EditError::new(
284        ErrorCode::PlanTooLarge,
285        "line index allocation could not be reserved",
286    )
287}
288
289fn resolve_character(
290    line: &str,
291    absolute_start: usize,
292    position: Position,
293    encoding: PositionEncoding,
294) -> Result<usize, EditError> {
295    let target = usize::try_from(position.character)
296        .map_err(|_| position_error(position, "character is too large"))?;
297    if line.is_ascii() || encoding == PositionEncoding::Utf8 {
298        if encoding == PositionEncoding::Utf8 && !line.is_char_boundary(target) {
299            return Err(position_error(
300                position,
301                "UTF-8 character offset splits a Unicode scalar value",
302            ));
303        }
304        return (target <= line.len())
305            .then_some(absolute_start + target)
306            .ok_or_else(|| position_error(position, "character exceeds line length"));
307    }
308
309    let mut utf16_offset = 0_usize;
310    for (byte_offset, character) in line.char_indices() {
311        if utf16_offset == target {
312            return Ok(absolute_start + byte_offset);
313        }
314        let next = utf16_offset
315            + match encoding {
316                PositionEncoding::Utf8 => character.len_utf8(),
317                PositionEncoding::Utf16 => character.len_utf16(),
318                PositionEncoding::Utf32 => 1,
319            };
320        if target < next {
321            return Err(position_error(
322                position,
323                "character splits a Unicode scalar value",
324            ));
325        }
326        utf16_offset = next;
327    }
328    if utf16_offset == target {
329        Ok(absolute_start + line.len())
330    } else {
331        Err(position_error(position, "character exceeds line length"))
332    }
333}
334
335fn count_units(text: &str, encoding: PositionEncoding) -> usize {
336    match encoding {
337        PositionEncoding::Utf8 => text.len(),
338        PositionEncoding::Utf16 => text.encode_utf16().count(),
339        PositionEncoding::Utf32 => text.chars().count(),
340    }
341}
342
343fn position_error(position: Position, message: &str) -> EditError {
344    EditError::new(
345        ErrorCode::PositionOutOfRange,
346        format!("{message} at {}:{}", position.line, position.character),
347    )
348}
349
350#[cfg(test)]
351mod tests {
352    use crate::{error::ErrorCode, model::Position};
353
354    #[test]
355    fn indexes_lf_crlf_and_final_empty_line() {
356        let index = super::LineIndex::new("a\r\nemoji 😀\n");
357        assert_eq!(index.line_count(), 3);
358        assert_eq!(index.byte_offset(Position::new(1, 2)).unwrap(), 2);
359        assert_eq!(index.byte_offset(Position::new(2, 8)).unwrap(), 13);
360        assert_eq!(index.byte_offset(Position::new(3, 0)).unwrap(), 14);
361    }
362
363    #[test]
364    fn rejects_one_past_lf_and_split_surrogate() {
365        let index = super::LineIndex::new("a\nb😀");
366        assert_eq!(
367            index.byte_offset(Position::new(1, 2)).unwrap_err().code(),
368            ErrorCode::PositionOutOfRange
369        );
370        assert_eq!(
371            index.byte_offset(Position::new(2, 2)).unwrap_err().code(),
372            ErrorCode::PositionOutOfRange
373        );
374    }
375}