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, Hash)]
16pub enum PositionEncoding {
17 #[default]
19 UTF16,
20 UTF32,
22 UTF8,
24}
25
26#[derive(Clone, Debug)]
28pub enum DocumentKey {
29 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}