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/// Client/server position encoding negotiated for LSP text ranges.
15#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum PositionEncoding {
17    /// UTF-16 code-unit positions, the LSP default.
18    #[default]
19    UTF16,
20    /// UTF-32 code-point positions.
21    UTF32,
22    /// UTF-8 byte positions.
23    UTF8,
24}
25
26/// Key identifying an open document in the server index.
27#[derive(Clone, Debug)]
28pub enum DocumentKey {
29    /// Text document identified by its URI.
30    Text(Url),
31}
32
33impl DocumentKey {
34    pub(crate) fn into_url(self) -> Url {
35        match self {
36            Self::Text(url) => url,
37        }
38    }
39}
40
41impl std::fmt::Display for DocumentKey {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Text(url) => url.fmt(f),
45        }
46    }
47}
48
49impl From<PositionEncoding> for PositionEncodingKind {
50    fn from(value: PositionEncoding) -> Self {
51        match value {
52            PositionEncoding::UTF8 => PositionEncodingKind::UTF8,
53            PositionEncoding::UTF16 => PositionEncodingKind::UTF16,
54            PositionEncoding::UTF32 => PositionEncodingKind::UTF32,
55        }
56    }
57}
58
59impl TryFrom<&PositionEncodingKind> for PositionEncoding {
60    type Error = ();
61
62    fn try_from(value: &PositionEncodingKind) -> Result<Self, Self::Error> {
63        Ok(if value == &PositionEncodingKind::UTF8 {
64            Self::UTF8
65        } else if value == &PositionEncodingKind::UTF16 {
66            Self::UTF16
67        } else if value == &PositionEncodingKind::UTF32 {
68            Self::UTF32
69        } else {
70            return Err(());
71        })
72    }
73}
74
75fn clamp_offset_to_char_boundary(text: &str, offset: usize) -> usize {
76    let mut clamped = offset.min(text.len());
77    while clamped > 0 && !text.is_char_boundary(clamped) {
78        clamped -= 1;
79    }
80    clamped
81}
82
83fn offset_to_position(
84    text: &str,
85    index: &shuck_indexer::LineIndex,
86    offset: usize,
87    encoding: PositionEncoding,
88) -> lsp_types::Position {
89    let offset = clamp_offset_to_char_boundary(text, offset);
90    let line = index.line_number(shuck_ast::TextSize::new(offset as u32));
91    let line_start = index.line_start(line).map(usize::from).unwrap_or_default();
92    let prefix = &text[line_start..offset];
93    let character = match encoding {
94        PositionEncoding::UTF8 => prefix.len(),
95        PositionEncoding::UTF16 => prefix.encode_utf16().count(),
96        PositionEncoding::UTF32 => prefix.chars().count(),
97    };
98
99    lsp_types::Position {
100        line: u32::try_from(line.saturating_sub(1)).unwrap_or(u32::MAX),
101        character: u32::try_from(character).unwrap_or(u32::MAX),
102    }
103}
104
105fn position_to_offset(
106    text: &str,
107    index: &shuck_indexer::LineIndex,
108    position: lsp_types::Position,
109    encoding: PositionEncoding,
110) -> usize {
111    let line = usize::try_from(position.line).unwrap_or(usize::MAX) + 1;
112    let line = line.min(index.line_count());
113    let line_start = index
114        .line_start(line)
115        .map(usize::from)
116        .unwrap_or(text.len());
117    let line_end = index
118        .line_range(line, text)
119        .map(|range| usize::from(range.end()))
120        .unwrap_or(text.len());
121    let line_text = &text[line_start..line_end];
122    let target = usize::try_from(position.character).unwrap_or(usize::MAX);
123
124    let relative = match encoding {
125        PositionEncoding::UTF8 => target.min(line_text.len()),
126        PositionEncoding::UTF16 => {
127            let mut units = 0usize;
128            let mut offset = line_text.len();
129            for (idx, ch) in line_text.char_indices() {
130                if units >= target {
131                    offset = idx;
132                    break;
133                }
134                units += ch.len_utf16();
135            }
136            if units < target {
137                line_text.len()
138            } else {
139                offset
140            }
141        }
142        PositionEncoding::UTF32 => {
143            let mut chars = 0usize;
144            let mut offset = line_text.len();
145            for (idx, _) in line_text.char_indices() {
146                if chars >= target {
147                    offset = idx;
148                    break;
149                }
150                chars += 1;
151            }
152            if chars < target {
153                line_text.len()
154            } else {
155                offset
156            }
157        }
158    };
159
160    clamp_offset_to_char_boundary(line_text, relative) + line_start
161}
162
163pub(crate) fn to_text_range(
164    range: &lsp_types::Range,
165    text: &str,
166    index: &shuck_indexer::LineIndex,
167    encoding: PositionEncoding,
168) -> TextRange {
169    let start = position_to_offset(text, index, range.start, encoding);
170    let end = position_to_offset(text, index, range.end, encoding);
171    TextRange::new(
172        shuck_ast::TextSize::new(start.min(end) as u32),
173        shuck_ast::TextSize::new(end.max(start) as u32),
174    )
175}
176
177pub(crate) fn to_lsp_range(
178    range: TextRange,
179    text: &str,
180    index: &shuck_indexer::LineIndex,
181    encoding: PositionEncoding,
182) -> lsp_types::Range {
183    lsp_types::Range {
184        start: offset_to_position(text, index, usize::from(range.start()), encoding),
185        end: offset_to_position(text, index, usize::from(range.end()), encoding),
186    }
187}
188
189pub(crate) fn single_replacement_edit(
190    text: &str,
191    replacement: &str,
192    index: &shuck_indexer::LineIndex,
193    encoding: PositionEncoding,
194) -> Option<lsp_types::TextEdit> {
195    if text == replacement {
196        return None;
197    }
198
199    let prefix_len = common_prefix_len(text, replacement);
200    let suffix_len = common_suffix_len(&text[prefix_len..], &replacement[prefix_len..]);
201    let original_end = text.len().saturating_sub(suffix_len);
202    let replacement_end = replacement.len().saturating_sub(suffix_len);
203
204    Some(lsp_types::TextEdit {
205        range: to_lsp_range(
206            TextRange::new(
207                shuck_ast::TextSize::new(prefix_len as u32),
208                shuck_ast::TextSize::new(original_end as u32),
209            ),
210            text,
211            index,
212            encoding,
213        ),
214        new_text: replacement[prefix_len..replacement_end].to_owned(),
215    })
216}
217
218pub(crate) fn single_replacement_edit_in_range(
219    text: &str,
220    range: TextRange,
221    replacement: &str,
222    index: &shuck_indexer::LineIndex,
223    encoding: PositionEncoding,
224) -> Option<lsp_types::TextEdit> {
225    let start = usize::from(range.start());
226    let end = usize::from(range.end());
227    let original = text
228        .get(start..end)
229        .expect("replacement range must use valid text boundaries");
230    if original == replacement {
231        return None;
232    }
233
234    let prefix_len = common_prefix_len(original, replacement);
235    let suffix_len = common_suffix_len(&original[prefix_len..], &replacement[prefix_len..]);
236    let original_end = original.len().saturating_sub(suffix_len);
237    let replacement_end = replacement.len().saturating_sub(suffix_len);
238
239    Some(lsp_types::TextEdit {
240        range: to_lsp_range(
241            TextRange::new(
242                shuck_ast::TextSize::new((start + prefix_len) as u32),
243                shuck_ast::TextSize::new((start + original_end) as u32),
244            ),
245            text,
246            index,
247            encoding,
248        ),
249        new_text: replacement[prefix_len..replacement_end].to_owned(),
250    })
251}
252
253fn common_prefix_len(left: &str, right: &str) -> usize {
254    left.chars()
255        .zip(right.chars())
256        .take_while(|(left, right)| left == right)
257        .map(|(ch, _)| ch.len_utf8())
258        .sum()
259}
260
261fn common_suffix_len(left: &str, right: &str) -> usize {
262    left.chars()
263        .rev()
264        .zip(right.chars().rev())
265        .take_while(|(left, right)| left == right)
266        .map(|(ch, _)| ch.len_utf8())
267        .sum()
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn single_replacement_edit_keeps_shared_prefix_and_suffix() {
276        let source = "echo old value\n";
277        let replacement = "echo new value\n";
278        let index = shuck_indexer::LineIndex::new(source);
279
280        let edit = single_replacement_edit(source, replacement, &index, PositionEncoding::UTF16)
281            .expect("edit should be present");
282
283        assert_eq!(
284            edit.range,
285            lsp_types::Range {
286                start: lsp_types::Position::new(0, 5),
287                end: lsp_types::Position::new(0, 8),
288            }
289        );
290        assert_eq!(edit.new_text, "new");
291    }
292
293    #[test]
294    fn single_replacement_edit_returns_none_for_identical_text() {
295        let source = "echo hi\n";
296        let index = shuck_indexer::LineIndex::new(source);
297
298        assert!(single_replacement_edit(source, source, &index, PositionEncoding::UTF16).is_none());
299    }
300
301    #[test]
302    fn ranged_replacement_edit_stays_within_target_range() {
303        let source = "echo one\necho two\n";
304        let index = shuck_indexer::LineIndex::new(source);
305        let edit = single_replacement_edit_in_range(
306            source,
307            TextRange::new(shuck_ast::TextSize::new(9), shuck_ast::TextSize::new(18)),
308            "printf two\n",
309            &index,
310            PositionEncoding::UTF16,
311        )
312        .expect("replacement should produce an edit");
313
314        assert_eq!(edit.range.start, lsp_types::Position::new(1, 0));
315        assert_eq!(edit.range.end, lsp_types::Position::new(1, 4));
316        assert_eq!(edit.new_text, "printf");
317    }
318}