Skip to main content

mcpls_core/bridge/
encoding.rs

1//! Position encoding conversion utilities.
2//!
3//! Handles conversion between MCP (1-based) and LSP (0-based) positions,
4//! as well as UTF-8/UTF-16/UTF-32 encoding conversions.
5
6use lsp_types::Position;
7
8/// Supported position encodings per LSP 3.17.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum PositionEncoding {
11    /// UTF-8 code units.
12    #[default]
13    Utf8,
14    /// UTF-16 code units (LSP default).
15    Utf16,
16    /// UTF-32 code units (Unicode code points).
17    Utf32,
18}
19
20impl PositionEncoding {
21    /// Parse from LSP position encoding kind string.
22    #[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    /// Convert to LSP position encoding kind string.
33    #[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/// Convert MCP position (1-based) to LSP position (0-based), translating the
44/// character column into `encoding`'s units.
45///
46/// MCP character columns are defined in UTF-16 code units -- the LSP default
47/// and what nearly every server negotiates -- so `PositionEncoding::Utf16`
48/// is a pure line/column offset with no further work, byte-for-byte
49/// identical to the fixed-encoding behavior this replaces. For any other
50/// negotiated encoding, `line_text` (the exact text of the target 0-based
51/// LSP line, without a line terminator) is used to re-derive the column in
52/// `encoding`'s units. If `line_text` is unavailable (e.g. the file could
53/// not be read) or the character offset is out of bounds for that line, the
54/// raw MCP character is used unconverted rather than failing the request.
55#[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/// Convert LSP position (0-based, in `encoding`'s units) to MCP position
82/// (1-based, UTF-16 code units).
83///
84/// The inverse of [`mcp_to_lsp_position`]; see its docs for the fast path
85/// and fallback behavior.
86#[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
105/// Resolve `character_offset` (in `encoding`'s units) to a byte offset in
106/// `text`, requiring the mapping to be exact.
107///
108/// `EncodingConverter::character_to_byte_offset` finds the byte boundary at
109/// or after the requested offset, so an offset that lands inside a
110/// multi-unit character (e.g. a UTF-16 surrogate pair) silently resolves to
111/// the *next* character boundary instead of erroring. Round-tripping the
112/// result back through `byte_offset_to_character` detects that case: if it
113/// doesn't reproduce `character_offset` exactly, the offset wasn't
114/// representable, and `None` signals the caller to fall back to the raw
115/// value rather than use a rounded-forward position.
116fn 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/// Position encoding converter for handling UTF-8/UTF-16/UTF-32 conversions.
130///
131/// Different LSP servers may use different character encodings. This converter
132/// handles the conversion between byte offsets and character offsets based on
133/// the negotiated encoding.
134#[derive(Debug, Clone)]
135pub struct EncodingConverter {
136    encoding: PositionEncoding,
137}
138
139impl EncodingConverter {
140    /// Create a new encoding converter with the specified encoding.
141    #[must_use]
142    pub const fn new(encoding: PositionEncoding) -> Self {
143        Self { encoding }
144    }
145
146    /// Convert byte offset to character offset in the configured encoding.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if:
151    /// - The byte offset is not on a character boundary
152    /// - The encoding is unsupported
153    #[allow(clippy::cast_possible_truncation)] // LSP positions use u32, truncation acceptable
154    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        // `text[..byte_offset]` below panics (and, under `panic = "abort"`,
162        // kills the whole process) if `byte_offset` lands mid-character. A
163        // server-reported offset should always be on a boundary, but this is
164        // untrusted external input, so it is checked rather than trusted.
165        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    /// Convert character offset to byte offset in the configured encoding.
185    ///
186    /// # Errors
187    ///
188    /// Returns an error if:
189    /// - The character offset is out of bounds
190    /// - The encoding is unsupported
191    #[allow(clippy::cast_possible_truncation)] // LSP positions use u32, truncation acceptable
192    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                // A UTF-8 "character offset" *is* a byte offset, taken
207                // directly from untrusted input (an LSP position from the
208                // server, or a re-derived offset from another encoding). It
209                // must land on a boundary before any caller slices `text`
210                // with it -- see `byte_offset_to_character`'s matching guard.
211                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        // Edge case: MCP position 0 should not underflow
312        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    /// Requirement: UTF-16 negotiated encoding must be byte-for-byte
318    /// identical to the pre-negotiation behavior, even when `line_text` is
319    /// supplied and contains multi-byte characters -- the fast path must
320    /// never consult it.
321    #[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    /// A UTF-8 negotiated server counts columns in bytes. `héllo` has one
339    /// multi-byte character (`é`, 2 bytes in UTF-8, 1 UTF-16 unit): the MCP
340    /// (UTF-16) column after `é` must be re-derived as one byte further in
341    /// UTF-8 terms.
342    #[test]
343    fn test_mcp_to_lsp_position_utf8_negotiated_multibyte() {
344        let line_text = "héllo";
345        // 1-based MCP column 3 sits right after "hé" (2 UTF-16 units).
346        let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
347        // In UTF-8 bytes, "hé" is 3 bytes (h=1, é=2).
348        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        // LSP (UTF-8 byte) position 3 = right after "hé".
355        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        // In UTF-16 units, "hé" is 2 units (h=1, é=1).
364        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    /// An out-of-bounds MCP character (e.g. stale client-side coordinates)
384    /// must fall back to the raw value rather than erroring the request.
385    #[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    /// C1 regression: a byte offset that lands mid-character must error, not
486    /// panic. `"héllo"` encodes `é` as the 2 bytes `0xC3 0xA9`; byte offset 2
487    /// sits between them. Before the boundary guard this reached
488    /// `text[..2].encode_utf16().count()` and panicked (and, under
489    /// `panic = "abort"`, aborted the whole process).
490    #[test]
491    fn test_byte_offset_to_character_mid_char_boundary_does_not_panic() {
492        let text = "héllo";
493        let byte_offset = 2; // inside 'é', not on a char boundary
494
495        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    /// C1 regression, `mcp_to_lsp_position`/`lsp_to_mcp_position` level: a
511    /// UTF-8-negotiated conversion whose intermediate byte offset lands
512    /// mid-character must fall back to the raw value (existing `unwrap_or`
513    /// behavior) rather than propagate a panic.
514    #[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, // inside 'é' in UTF-8 byte terms
521            },
522            Some(line_text),
523            PositionEncoding::Utf8,
524        );
525        assert_eq!(mcp_char, 3); // pos.character + 1, the raw fallback
526    }
527
528    /// Astral (non-BMP) characters on the UTF-8 negotiated path: `𝄞` (U+1D11E,
529    /// the musical G-clef) is 4 bytes in UTF-8 and 2 UTF-16 code units (a
530    /// surrogate pair).
531    #[test]
532    fn test_mcp_to_lsp_position_utf8_negotiated_astral_char() {
533        let line_text = "𝄞x";
534        // 1-based MCP column 3 sits right after the surrogate pair (2 UTF-16
535        // units) + 1 for 1-based indexing.
536        let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
537        assert_eq!(lsp_pos.character, 4); // 4 UTF-8 bytes for the astral char
538
539        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    /// Copilot review finding: an MCP character offset landing inside a
551    /// UTF-16 surrogate pair (e.g. a client miscounting an astral character)
552    /// must fall back to the raw offset, not silently round forward to the
553    /// byte offset *after* the whole character. `𝄞` (U+1D11E) is a surrogate
554    /// pair (2 UTF-16 units); MCP column 2 (1-based) sits between them.
555    #[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        // Falls back to the raw (unconverted) MCP character rather than
560        // rounding forward to byte offset 4 (right after the astral char).
561        assert_eq!(lsp_pos.character, 1);
562    }
563
564    /// CRLF line endings: `line_text` (as sourced by callers via `str::lines`)
565    /// never includes the terminator, so conversion math is identical to the
566    /// LF case -- this locks in that CRLF content doesn't shift columns.
567    #[test]
568    fn test_mcp_to_lsp_position_utf8_negotiated_crlf_line_text() {
569        let line_text = "héllo"; // as it would be yielded by "héllo\r\n".lines()
570        let lsp_pos = mcp_to_lsp_position(1, 3, Some(line_text), PositionEncoding::Utf8);
571        assert_eq!(lsp_pos.character, 3);
572    }
573}