Skip to main content

lspkit_vfs/
edit.rs

1//! Incremental edit application.
2//!
3//! Edits are described as `(range, replacement)` pairs over the negotiated
4//! position encoding. Applying an edit converts the range to UTF-8 byte
5//! offsets and splices the rope.
6
7use ropey::Rope;
8
9use crate::encoding::PositionEncoding;
10
11/// A zero-based `(line, character)` position in a document.
12///
13/// The `character` offset is interpreted in the document's negotiated
14/// [`PositionEncoding`].
15#[non_exhaustive]
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub struct Position {
18    /// Zero-based line number.
19    pub line: u32,
20    /// Zero-based character offset within the line.
21    pub character: u32,
22}
23
24impl Position {
25    /// Construct a position.
26    #[must_use]
27    pub const fn new(line: u32, character: u32) -> Self {
28        Self { line, character }
29    }
30}
31
32/// A half-open `[start, end)` range over positions.
33#[non_exhaustive]
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35pub struct Range {
36    /// Inclusive start position.
37    pub start: Position,
38    /// Exclusive end position.
39    pub end: Position,
40}
41
42impl Range {
43    /// Construct a range.
44    #[must_use]
45    pub const fn new(start: Position, end: Position) -> Self {
46        Self { start, end }
47    }
48}
49
50/// A single incremental edit: replace `range` with `text`.
51#[non_exhaustive]
52#[derive(Debug, Clone)]
53pub struct TextEdit {
54    /// The range to replace.
55    pub range: Range,
56    /// The replacement text.
57    pub text: String,
58}
59
60impl TextEdit {
61    /// Construct an edit.
62    #[must_use]
63    pub const fn new(range: Range, text: String) -> Self {
64        Self { range, text }
65    }
66}
67
68/// Errors from applying an edit.
69#[non_exhaustive]
70#[derive(Debug, thiserror::Error)]
71pub enum EditError {
72    /// The edit's position lies outside the document.
73    #[error("position out of bounds: line {line}, character {character}")]
74    OutOfBounds {
75        /// Offending line.
76        line: u32,
77        /// Offending character offset.
78        character: u32,
79    },
80}
81
82pub(crate) fn apply(
83    rope: &mut Rope,
84    edits: &[TextEdit],
85    encoding: PositionEncoding,
86) -> Result<(), EditError> {
87    for edit in edits {
88        let start = position_to_byte(rope, edit.range.start, encoding)?;
89        let end = position_to_byte(rope, edit.range.end, encoding)?;
90        let start_char = rope.byte_to_char(start);
91        let end_char = rope.byte_to_char(end);
92        rope.remove(start_char..end_char);
93        rope.insert(start_char, &edit.text);
94    }
95    Ok(())
96}
97
98fn position_to_byte(
99    rope: &Rope,
100    pos: Position,
101    encoding: PositionEncoding,
102) -> Result<usize, EditError> {
103    let line_idx = pos.line as usize;
104    if line_idx > rope.len_lines() {
105        return Err(EditError::OutOfBounds {
106            line: pos.line,
107            character: pos.character,
108        });
109    }
110    let line_byte_start = rope.line_to_byte(line_idx.min(rope.len_lines()));
111    let line = rope.line(line_idx.min(rope.len_lines().saturating_sub(1)));
112    let line_str = line.to_string();
113    let char_offset = match encoding {
114        PositionEncoding::Utf8 => pos.character as usize,
115        PositionEncoding::Utf16 => utf16_to_utf8_offset(&line_str, pos.character as usize)?,
116        PositionEncoding::Utf32 => utf32_to_utf8_offset(&line_str, pos.character as usize)?,
117    };
118    Ok(line_byte_start + char_offset)
119}
120
121fn utf16_to_utf8_offset(line: &str, utf16_units: usize) -> Result<usize, EditError> {
122    let mut counted = 0usize;
123    let mut byte_offset = 0usize;
124    for ch in line.chars() {
125        if counted >= utf16_units {
126            break;
127        }
128        counted += ch.len_utf16();
129        byte_offset += ch.len_utf8();
130    }
131    if counted < utf16_units {
132        return Err(EditError::OutOfBounds {
133            line: u32::MAX,
134            character: u32::try_from(utf16_units).unwrap_or(u32::MAX),
135        });
136    }
137    Ok(byte_offset)
138}
139
140fn utf32_to_utf8_offset(line: &str, code_points: usize) -> Result<usize, EditError> {
141    line.char_indices().nth(code_points).map_or_else(
142        || {
143            if line.chars().count() == code_points {
144                Ok(line.len())
145            } else {
146                Err(EditError::OutOfBounds {
147                    line: u32::MAX,
148                    character: u32::try_from(code_points).unwrap_or(u32::MAX),
149                })
150            }
151        },
152        |(byte_idx, _)| Ok(byte_idx),
153    )
154}