Skip to main content

shuck_server/
edit.rs

1#![allow(dead_code)]
2
3mod range;
4mod text_document;
5
6use lsp_types::{PositionEncodingKind, Url};
7use shuck_ast::TextRange;
8
9pub(crate) use range::RangeExt;
10pub(crate) use text_document::DocumentVersion;
11pub(crate) use text_document::LanguageId;
12pub use text_document::TextDocument;
13
14#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
15pub enum PositionEncoding {
16    #[default]
17    UTF16,
18    UTF32,
19    UTF8,
20}
21
22#[derive(Clone, Debug)]
23pub enum DocumentKey {
24    Text(Url),
25}
26
27impl DocumentKey {
28    pub(crate) fn into_url(self) -> Url {
29        match self {
30            Self::Text(url) => url,
31        }
32    }
33}
34
35impl std::fmt::Display for DocumentKey {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            Self::Text(url) => url.fmt(f),
39        }
40    }
41}
42
43impl From<PositionEncoding> for PositionEncodingKind {
44    fn from(value: PositionEncoding) -> Self {
45        match value {
46            PositionEncoding::UTF8 => PositionEncodingKind::UTF8,
47            PositionEncoding::UTF16 => PositionEncodingKind::UTF16,
48            PositionEncoding::UTF32 => PositionEncodingKind::UTF32,
49        }
50    }
51}
52
53impl TryFrom<&PositionEncodingKind> for PositionEncoding {
54    type Error = ();
55
56    fn try_from(value: &PositionEncodingKind) -> Result<Self, Self::Error> {
57        Ok(if value == &PositionEncodingKind::UTF8 {
58            Self::UTF8
59        } else if value == &PositionEncodingKind::UTF16 {
60            Self::UTF16
61        } else if value == &PositionEncodingKind::UTF32 {
62            Self::UTF32
63        } else {
64            return Err(());
65        })
66    }
67}
68
69fn clamp_offset_to_char_boundary(text: &str, offset: usize) -> usize {
70    let mut clamped = offset.min(text.len());
71    while clamped > 0 && !text.is_char_boundary(clamped) {
72        clamped -= 1;
73    }
74    clamped
75}
76
77fn offset_to_position(
78    text: &str,
79    index: &shuck_indexer::LineIndex,
80    offset: usize,
81    encoding: PositionEncoding,
82) -> lsp_types::Position {
83    let offset = clamp_offset_to_char_boundary(text, offset);
84    let line = index.line_number(shuck_ast::TextSize::new(offset as u32));
85    let line_start = index.line_start(line).map(usize::from).unwrap_or_default();
86    let prefix = &text[line_start..offset];
87    let character = match encoding {
88        PositionEncoding::UTF8 => prefix.len(),
89        PositionEncoding::UTF16 => prefix.encode_utf16().count(),
90        PositionEncoding::UTF32 => prefix.chars().count(),
91    };
92
93    lsp_types::Position {
94        line: u32::try_from(line.saturating_sub(1)).unwrap_or(u32::MAX),
95        character: u32::try_from(character).unwrap_or(u32::MAX),
96    }
97}
98
99fn position_to_offset(
100    text: &str,
101    index: &shuck_indexer::LineIndex,
102    position: lsp_types::Position,
103    encoding: PositionEncoding,
104) -> usize {
105    let line = usize::try_from(position.line).unwrap_or(usize::MAX) + 1;
106    let line = line.min(index.line_count());
107    let line_start = index
108        .line_start(line)
109        .map(usize::from)
110        .unwrap_or(text.len());
111    let line_end = index
112        .line_range(line, text)
113        .map(|range| usize::from(range.end()))
114        .unwrap_or(text.len());
115    let line_text = &text[line_start..line_end];
116    let target = usize::try_from(position.character).unwrap_or(usize::MAX);
117
118    let relative = match encoding {
119        PositionEncoding::UTF8 => target.min(line_text.len()),
120        PositionEncoding::UTF16 => {
121            let mut units = 0usize;
122            let mut offset = line_text.len();
123            for (idx, ch) in line_text.char_indices() {
124                if units >= target {
125                    offset = idx;
126                    break;
127                }
128                units += ch.len_utf16();
129            }
130            if units < target {
131                line_text.len()
132            } else {
133                offset
134            }
135        }
136        PositionEncoding::UTF32 => {
137            let mut chars = 0usize;
138            let mut offset = line_text.len();
139            for (idx, _) in line_text.char_indices() {
140                if chars >= target {
141                    offset = idx;
142                    break;
143                }
144                chars += 1;
145            }
146            if chars < target {
147                line_text.len()
148            } else {
149                offset
150            }
151        }
152    };
153
154    clamp_offset_to_char_boundary(line_text, relative) + line_start
155}
156
157pub(crate) fn to_text_range(
158    range: &lsp_types::Range,
159    text: &str,
160    index: &shuck_indexer::LineIndex,
161    encoding: PositionEncoding,
162) -> TextRange {
163    let start = position_to_offset(text, index, range.start, encoding);
164    let end = position_to_offset(text, index, range.end, encoding);
165    TextRange::new(
166        shuck_ast::TextSize::new(start.min(end) as u32),
167        shuck_ast::TextSize::new(end.max(start) as u32),
168    )
169}
170
171pub(crate) fn to_lsp_range(
172    range: TextRange,
173    text: &str,
174    index: &shuck_indexer::LineIndex,
175    encoding: PositionEncoding,
176) -> lsp_types::Range {
177    lsp_types::Range {
178        start: offset_to_position(text, index, usize::from(range.start()), encoding),
179        end: offset_to_position(text, index, usize::from(range.end()), encoding),
180    }
181}
182
183pub(crate) fn single_replacement_edit(
184    text: &str,
185    replacement: &str,
186    index: &shuck_indexer::LineIndex,
187    encoding: PositionEncoding,
188) -> Option<lsp_types::TextEdit> {
189    if text == replacement {
190        return None;
191    }
192
193    let prefix_len = common_prefix_len(text, replacement);
194    let suffix_len = common_suffix_len(&text[prefix_len..], &replacement[prefix_len..]);
195    let original_end = text.len().saturating_sub(suffix_len);
196    let replacement_end = replacement.len().saturating_sub(suffix_len);
197
198    Some(lsp_types::TextEdit {
199        range: to_lsp_range(
200            TextRange::new(
201                shuck_ast::TextSize::new(prefix_len as u32),
202                shuck_ast::TextSize::new(original_end as u32),
203            ),
204            text,
205            index,
206            encoding,
207        ),
208        new_text: replacement[prefix_len..replacement_end].to_owned(),
209    })
210}
211
212fn common_prefix_len(left: &str, right: &str) -> usize {
213    left.chars()
214        .zip(right.chars())
215        .take_while(|(left, right)| left == right)
216        .map(|(ch, _)| ch.len_utf8())
217        .sum()
218}
219
220fn common_suffix_len(left: &str, right: &str) -> usize {
221    left.chars()
222        .rev()
223        .zip(right.chars().rev())
224        .take_while(|(left, right)| left == right)
225        .map(|(ch, _)| ch.len_utf8())
226        .sum()
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn single_replacement_edit_keeps_shared_prefix_and_suffix() {
235        let source = "echo old value\n";
236        let replacement = "echo new value\n";
237        let index = shuck_indexer::LineIndex::new(source);
238
239        let edit = single_replacement_edit(source, replacement, &index, PositionEncoding::UTF16)
240            .expect("edit should be present");
241
242        assert_eq!(
243            edit.range,
244            lsp_types::Range {
245                start: lsp_types::Position::new(0, 5),
246                end: lsp_types::Position::new(0, 8),
247            }
248        );
249        assert_eq!(edit.new_text, "new");
250    }
251
252    #[test]
253    fn single_replacement_edit_returns_none_for_identical_text() {
254        let source = "echo hi\n";
255        let index = shuck_indexer::LineIndex::new(source);
256
257        assert!(single_replacement_edit(source, source, &index, PositionEncoding::UTF16).is_none());
258    }
259}