1use lsp_types::Position;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum PositionEncoding {
11 #[default]
13 Utf8,
14 Utf16,
16 Utf32,
18}
19
20impl PositionEncoding {
21 #[must_use]
23 pub fn from_lsp(kind: &str) -> Option<Self> {
24 match kind {
25 "utf-8" => Some(Self::Utf8),
26 "utf-16" => Some(Self::Utf16),
27 "utf-32" => Some(Self::Utf32),
28 _ => None,
29 }
30 }
31
32 #[must_use]
34 pub const fn to_lsp(&self) -> &'static str {
35 match self {
36 Self::Utf8 => "utf-8",
37 Self::Utf16 => "utf-16",
38 Self::Utf32 => "utf-32",
39 }
40 }
41}
42
43#[must_use]
56pub fn mcp_to_lsp_position(
57 line: u32,
58 character: u32,
59 line_text: Option<&str>,
60 encoding: PositionEncoding,
61) -> Position {
62 let lsp_line = line.saturating_sub(1);
63 let mcp_character = character.saturating_sub(1);
64
65 let lsp_character = match (encoding, line_text) {
66 (PositionEncoding::Utf16, _) | (_, None) => mcp_character,
67 (_, Some(text)) => {
68 let target = EncodingConverter::new(encoding);
69 exact_byte_offset(text, mcp_character, PositionEncoding::Utf16)
70 .and_then(|byte_offset| target.byte_offset_to_character(text, byte_offset).ok())
71 .unwrap_or(mcp_character)
72 }
73 };
74
75 Position {
76 line: lsp_line,
77 character: lsp_character,
78 }
79}
80
81#[must_use]
87pub fn lsp_to_mcp_position(
88 pos: Position,
89 line_text: Option<&str>,
90 encoding: PositionEncoding,
91) -> (u32, u32) {
92 let mcp_character = match (encoding, line_text) {
93 (PositionEncoding::Utf16, _) | (_, None) => pos.character,
94 (_, Some(text)) => {
95 let utf16 = EncodingConverter::new(PositionEncoding::Utf16);
96 exact_byte_offset(text, pos.character, encoding)
97 .and_then(|byte_offset| utf16.byte_offset_to_character(text, byte_offset).ok())
98 .unwrap_or(pos.character)
99 }
100 };
101
102 (pos.line + 1, mcp_character + 1)
103}
104
105fn exact_byte_offset(
117 text: &str,
118 character_offset: u32,
119 encoding: PositionEncoding,
120) -> Option<usize> {
121 let converter = EncodingConverter::new(encoding);
122 let byte_offset = converter
123 .character_to_byte_offset(text, character_offset)
124 .ok()?;
125 let round_trip = converter.byte_offset_to_character(text, byte_offset).ok()?;
126 (round_trip == character_offset).then_some(byte_offset)
127}
128
129#[derive(Debug, Clone)]
135pub struct EncodingConverter {
136 encoding: PositionEncoding,
137}
138
139impl EncodingConverter {
140 #[must_use]
142 pub const fn new(encoding: PositionEncoding) -> Self {
143 Self { encoding }
144 }
145
146 #[allow(clippy::cast_possible_truncation)] pub fn byte_offset_to_character(&self, text: &str, byte_offset: usize) -> Result<u32, String> {
155 if byte_offset > text.len() {
156 let text_len = text.len();
157 return Err(format!(
158 "Byte offset {byte_offset} exceeds text length {text_len}"
159 ));
160 }
161 if !text.is_char_boundary(byte_offset) {
166 return Err(format!(
167 "Byte offset {byte_offset} is not on a character boundary"
168 ));
169 }
170
171 match self.encoding {
172 PositionEncoding::Utf8 => Ok(byte_offset as u32),
173 PositionEncoding::Utf16 => {
174 let utf16_units = text[..byte_offset].encode_utf16().count();
175 Ok(utf16_units as u32)
176 }
177 PositionEncoding::Utf32 => {
178 let code_points = text[..byte_offset].chars().count();
179 Ok(code_points as u32)
180 }
181 }
182 }
183
184 #[allow(clippy::cast_possible_truncation)] pub fn character_to_byte_offset(
193 &self,
194 text: &str,
195 character_offset: u32,
196 ) -> Result<usize, String> {
197 match self.encoding {
198 PositionEncoding::Utf8 => {
199 let byte_offset = character_offset as usize;
200 if byte_offset > text.len() {
201 let text_len = text.len();
202 return Err(format!(
203 "Character offset {character_offset} exceeds text length {text_len}"
204 ));
205 }
206 if !text.is_char_boundary(byte_offset) {
212 return Err(format!(
213 "Character offset {character_offset} is not on a character boundary"
214 ));
215 }
216 Ok(byte_offset)
217 }
218 PositionEncoding::Utf16 => {
219 let mut utf16_count = 0u32;
220 for (byte_idx, ch) in text.char_indices() {
221 if utf16_count >= character_offset {
222 return Ok(byte_idx);
223 }
224 utf16_count += ch.len_utf16() as u32;
225 }
226 if utf16_count == character_offset {
227 Ok(text.len())
228 } else {
229 Err(format!(
230 "Character offset {character_offset} out of bounds (max UTF-16 units: {utf16_count})"
231 ))
232 }
233 }
234 PositionEncoding::Utf32 => text
235 .char_indices()
236 .nth(character_offset as usize)
237 .map(|(byte_idx, _)| byte_idx)
238 .or_else(|| {
239 if character_offset == text.chars().count() as u32 {
240 Some(text.len())
241 } else {
242 None
243 }
244 })
245 .ok_or_else(|| {
246 let max_code_points = text.chars().count();
247 format!(
248 "Character offset {character_offset} out of bounds (max code points: {max_code_points})"
249 )
250 }),
251 }
252 }
253}
254
255#[cfg(test)]
256#[allow(clippy::unwrap_used)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn test_mcp_to_lsp_position() {
262 let lsp_pos = mcp_to_lsp_position(1, 1, None, PositionEncoding::Utf16);
263 assert_eq!(lsp_pos.line, 0);
264 assert_eq!(lsp_pos.character, 0);
265
266 let lsp_pos = mcp_to_lsp_position(10, 5, None, PositionEncoding::Utf16);
267 assert_eq!(lsp_pos.line, 9);
268 assert_eq!(lsp_pos.character, 4);
269 }
270
271 #[test]
272 fn test_lsp_to_mcp_position() {
273 let (line, char) = lsp_to_mcp_position(
274 Position {
275 line: 0,
276 character: 0,
277 },
278 None,
279 PositionEncoding::Utf16,
280 );
281 assert_eq!(line, 1);
282 assert_eq!(char, 1);
283
284 let (line, char) = lsp_to_mcp_position(
285 Position {
286 line: 9,
287 character: 4,
288 },
289 None,
290 PositionEncoding::Utf16,
291 );
292 assert_eq!(line, 10);
293 assert_eq!(char, 5);
294 }
295
296 #[test]
297 fn test_roundtrip() {
298 for line in 1..100 {
299 for char in 1..100 {
300 let lsp_pos = mcp_to_lsp_position(line, char, None, PositionEncoding::Utf16);
301 let (mcp_line, mcp_char) =
302 lsp_to_mcp_position(lsp_pos, None, PositionEncoding::Utf16);
303 assert_eq!(line, mcp_line);
304 assert_eq!(char, mcp_char);
305 }
306 }
307 }
308
309 #[test]
310 fn test_saturating_sub_zero() {
311 let lsp_pos = mcp_to_lsp_position(0, 0, None, PositionEncoding::Utf16);
313 assert_eq!(lsp_pos.line, 0);
314 assert_eq!(lsp_pos.character, 0);
315 }
316
317 #[test]
322 fn test_utf16_negotiated_ignores_line_text() {
323 let line_text = "let 😀 = \"héllo\";";
324 let lsp_pos = mcp_to_lsp_position(1, 6, Some(line_text), PositionEncoding::Utf16);
325 assert_eq!(lsp_pos.character, 5);
326
327 let (_, mcp_char) = lsp_to_mcp_position(
328 Position {
329 line: 0,
330 character: 5,
331 },
332 Some(line_text),
333 PositionEncoding::Utf16,
334 );
335 assert_eq!(mcp_char, 6);
336 }
337
338 #[test]
343 fn test_mcp_to_lsp_position_utf8_negotiated_multibyte() {
344 let line_text = "héllo";
345 let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
347 assert_eq!(lsp_pos.character, 3);
349 }
350
351 #[test]
352 fn test_lsp_to_mcp_position_utf8_negotiated_multibyte() {
353 let line_text = "héllo";
354 let (_, mcp_char) = lsp_to_mcp_position(
356 Position {
357 line: 0,
358 character: 3,
359 },
360 Some(line_text),
361 PositionEncoding::Utf8,
362 );
363 assert_eq!(mcp_char, 3);
365 }
366
367 #[test]
368 fn test_mcp_to_lsp_position_ascii_identical_across_encodings() {
369 let line_text = "let x = 5;";
370 for encoding in [
371 PositionEncoding::Utf8,
372 PositionEncoding::Utf16,
373 PositionEncoding::Utf32,
374 ] {
375 let pos = mcp_to_lsp_position(1, 5, Some(line_text), encoding);
376 assert_eq!(
377 pos.character, 4,
378 "encoding {encoding:?} must agree on ASCII"
379 );
380 }
381 }
382
383 #[test]
386 fn test_mcp_to_lsp_position_out_of_bounds_falls_back() {
387 let line_text = "short";
388 let pos = mcp_to_lsp_position(1, 1000, Some(line_text), PositionEncoding::Utf8);
389 assert_eq!(pos.character, 999);
390 }
391
392 #[test]
393 fn test_mcp_to_lsp_position_missing_line_text_falls_back() {
394 let pos = mcp_to_lsp_position(1, 4, None, PositionEncoding::Utf8);
395 assert_eq!(pos.character, 3);
396 }
397
398 #[test]
399 fn test_position_encoding_parsing() {
400 assert_eq!(
401 PositionEncoding::from_lsp("utf-8"),
402 Some(PositionEncoding::Utf8)
403 );
404 assert_eq!(
405 PositionEncoding::from_lsp("utf-16"),
406 Some(PositionEncoding::Utf16)
407 );
408 assert_eq!(
409 PositionEncoding::from_lsp("utf-32"),
410 Some(PositionEncoding::Utf32)
411 );
412 assert_eq!(PositionEncoding::from_lsp("invalid"), None);
413 }
414
415 #[test]
416 fn test_utf8_encoding() {
417 let converter = EncodingConverter::new(PositionEncoding::Utf8);
418 let text = "Hello, world!";
419
420 let char_offset = converter.byte_offset_to_character(text, 7).unwrap();
421 assert_eq!(char_offset, 7);
422
423 let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
424 assert_eq!(byte_offset, 7);
425 }
426
427 #[test]
428 fn test_utf16_encoding_with_emoji() {
429 let converter = EncodingConverter::new(PositionEncoding::Utf16);
430 let text = "Hello 😀 world";
431
432 let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
433 assert_eq!(char_offset, 6);
434
435 let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
436 assert_eq!(char_offset, 8);
437
438 let byte_offset = converter.character_to_byte_offset(text, 6).unwrap();
439 assert_eq!(byte_offset, 6);
440
441 let byte_offset = converter.character_to_byte_offset(text, 8).unwrap();
442 assert_eq!(byte_offset, 10);
443 }
444
445 #[test]
446 fn test_utf16_encoding_roundtrip() {
447 let converter = EncodingConverter::new(PositionEncoding::Utf16);
448 let text = "Hello 🌍 world!";
449
450 for byte_idx in [0, 6, 10, 11] {
451 let char_offset = converter.byte_offset_to_character(text, byte_idx).unwrap();
452 let back_to_byte = converter
453 .character_to_byte_offset(text, char_offset)
454 .unwrap();
455 assert_eq!(byte_idx, back_to_byte);
456 }
457 }
458
459 #[test]
460 fn test_utf32_encoding() {
461 let converter = EncodingConverter::new(PositionEncoding::Utf32);
462 let text = "Hello 😀 world";
463
464 let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
465 assert_eq!(char_offset, 6);
466
467 let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
468 assert_eq!(char_offset, 7);
469
470 let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
471 assert_eq!(byte_offset, 10);
472 }
473
474 #[test]
475 fn test_encoding_edge_cases() {
476 let converter = EncodingConverter::new(PositionEncoding::Utf8);
477
478 assert!(converter.byte_offset_to_character("test", 100).is_err());
479 assert!(converter.character_to_byte_offset("test", 100).is_err());
480
481 let end_offset = converter.byte_offset_to_character("test", 4).unwrap();
482 assert_eq!(end_offset, 4);
483 }
484
485 #[test]
491 fn test_byte_offset_to_character_mid_char_boundary_does_not_panic() {
492 let text = "héllo";
493 let byte_offset = 2; for encoding in [
496 PositionEncoding::Utf8,
497 PositionEncoding::Utf16,
498 PositionEncoding::Utf32,
499 ] {
500 let converter = EncodingConverter::new(encoding);
501 assert!(
502 converter
503 .byte_offset_to_character(text, byte_offset)
504 .is_err(),
505 "encoding {encoding:?} must reject a mid-character byte offset instead of panicking"
506 );
507 }
508 }
509
510 #[test]
515 fn test_lsp_to_mcp_position_utf8_mid_char_lsp_offset_falls_back() {
516 let line_text = "héllo";
517 let (_, mcp_char) = lsp_to_mcp_position(
518 Position {
519 line: 0,
520 character: 2, },
522 Some(line_text),
523 PositionEncoding::Utf8,
524 );
525 assert_eq!(mcp_char, 3); }
527
528 #[test]
532 fn test_mcp_to_lsp_position_utf8_negotiated_astral_char() {
533 let line_text = "𝄞x";
534 let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
537 assert_eq!(lsp_pos.character, 4); let (_, mcp_char) = lsp_to_mcp_position(
540 Position {
541 line: 0,
542 character: 4,
543 },
544 Some(line_text),
545 PositionEncoding::Utf8,
546 );
547 assert_eq!(mcp_char, 3);
548 }
549
550 #[test]
556 fn test_mcp_to_lsp_position_mid_surrogate_falls_back() {
557 let line_text = "𝄞x";
558 let lsp_pos = mcp_to_lsp_position(1, 2, Some(line_text), PositionEncoding::Utf8);
559 assert_eq!(lsp_pos.character, 1);
562 }
563
564 #[test]
568 fn test_mcp_to_lsp_position_utf8_negotiated_crlf_line_text() {
569 let line_text = "héllo"; let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
571 assert_eq!(lsp_pos.character, 3);
572 }
573}