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 serde::{Deserialize, Serialize};
7
8use super::translator::{Position, Position2D};
9
10/// Supported position encodings per LSP 3.17.
11///
12/// Serializes as the LSP kind string (`"utf-8"`, `"utf-16"`, `"utf-32"`) and
13/// rejects any other string on deserialize.
14///
15/// # Examples
16///
17/// ```
18/// use mcpls_core::bridge::PositionEncoding;
19///
20/// assert_eq!(PositionEncoding::from_lsp("utf-32"), Some(PositionEncoding::Utf32));
21/// assert!(PositionEncoding::try_from("utf-7".to_string()).is_err());
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
24#[serde(try_from = "String", into = "&'static str")]
25pub enum PositionEncoding {
26    /// UTF-8 code units.
27    #[default]
28    Utf8,
29    /// UTF-16 code units (LSP default).
30    Utf16,
31    /// UTF-32 code units (Unicode code points).
32    Utf32,
33}
34
35/// A string that is not an LSP position encoding kind.
36#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
37#[error("invalid position encoding '{0}'; expected one of \"utf-8\", \"utf-16\", \"utf-32\"")]
38pub struct InvalidPositionEncoding(String);
39
40impl PositionEncoding {
41    /// Every encoding, in LSP-spec order.
42    pub const ALL: [Self; 3] = [Self::Utf8, Self::Utf16, Self::Utf32];
43
44    /// Parse from LSP position encoding kind string.
45    #[must_use]
46    pub fn from_lsp(kind: &str) -> Option<Self> {
47        Self::ALL.into_iter().find(|e| e.to_lsp() == kind)
48    }
49
50    /// Convert to LSP position encoding kind string.
51    #[must_use]
52    pub const fn to_lsp(&self) -> &'static str {
53        match self {
54            Self::Utf8 => "utf-8",
55            Self::Utf16 => "utf-16",
56            Self::Utf32 => "utf-32",
57        }
58    }
59
60    /// Convert to the [`lsp_types::PositionEncodingKind`] sent in `initialize`.
61    #[must_use]
62    pub const fn to_kind(self) -> lsp_types::PositionEncodingKind {
63        match self {
64            Self::Utf8 => lsp_types::PositionEncodingKind::UTF8,
65            Self::Utf16 => lsp_types::PositionEncodingKind::UTF16,
66            Self::Utf32 => lsp_types::PositionEncodingKind::UTF32,
67        }
68    }
69}
70
71impl TryFrom<String> for PositionEncoding {
72    type Error = InvalidPositionEncoding;
73
74    fn try_from(kind: String) -> Result<Self, Self::Error> {
75        Self::from_lsp(&kind).ok_or(InvalidPositionEncoding(kind))
76    }
77}
78
79impl From<PositionEncoding> for &'static str {
80    fn from(encoding: PositionEncoding) -> Self {
81        encoding.to_lsp()
82    }
83}
84
85/// Whether a column conversion preserved the exact character the caller named.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ColumnFidelity {
88    /// The column is correct in the target encoding.
89    Exact,
90    /// The column could not be converted and was passed through as-is.
91    PassedThrough,
92}
93
94/// A converted value together with whether its column conversion was exact.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[must_use]
97pub struct Converted<T> {
98    /// The converted value.
99    pub value: T,
100    /// Whether the column inside `value` is exact in the target encoding.
101    pub fidelity: ColumnFidelity,
102}
103
104impl<T> Converted<T> {
105    const fn exact(value: T) -> Self {
106        Self {
107            value,
108            fidelity: ColumnFidelity::Exact,
109        }
110    }
111
112    const fn passed_through(value: T) -> Self {
113        Self {
114            value,
115            fidelity: ColumnFidelity::PassedThrough,
116        }
117    }
118}
119
120/// Convert MCP position (1-based) to LSP position (0-based), translating the
121/// character column into `encoding`'s units.
122///
123/// MCP character columns are defined in UTF-16 code units -- the LSP default
124/// and what nearly every server negotiates -- so `PositionEncoding::Utf16`
125/// is a pure line/column offset with no further work, and column 0 is
126/// identical in every encoding. Otherwise `line_text` (the exact text of the
127/// target 0-based LSP line, without a line terminator) is used to re-derive
128/// the column in `encoding`'s units; a column past the end of the line clamps
129/// to the line length, per LSP 3.17. If `line_text` is unavailable (e.g. the
130/// file could not be read) or the column lands inside a multi-unit
131/// character, the raw MCP character is used unconverted rather than failing
132/// the request, and the result's [`Converted::fidelity`] is
133/// [`ColumnFidelity::PassedThrough`].
134pub fn mcp_to_lsp_position(
135    position: Position,
136    line_text: Option<&str>,
137    encoding: PositionEncoding,
138) -> Converted<lsp_types::Position> {
139    let column = convert_column(
140        position.lsp_character(),
141        line_text,
142        PositionEncoding::Utf16,
143        encoding,
144    );
145    Converted {
146        value: lsp_types::Position {
147            line: position.lsp_line(),
148            character: column.value,
149        },
150        fidelity: column.fidelity,
151    }
152}
153
154/// Convert LSP position (0-based, in `encoding`'s units) to MCP position
155/// (1-based, UTF-16 code units).
156///
157/// The inverse of [`mcp_to_lsp_position`]; see its docs for the fast path
158/// and fallback behavior. `character == u32::MAX`, the LSP "end of line"
159/// idiom, is always [`ColumnFidelity::Exact`] and returned unchanged.
160pub fn lsp_to_mcp_position(
161    pos: lsp_types::Position,
162    line_text: Option<&str>,
163    encoding: PositionEncoding,
164) -> Converted<Position2D> {
165    let column = if pos.character == u32::MAX {
166        Converted::exact(u32::MAX)
167    } else {
168        convert_column(pos.character, line_text, encoding, PositionEncoding::Utf16)
169    };
170
171    Converted {
172        value: Position2D {
173            line: pos.line.saturating_add(1),
174            character: column.value.saturating_add(1),
175        },
176        fidelity: column.fidelity,
177    }
178}
179
180/// Re-derive a 0-based `column` from `from` units into `to` units, deciding
181/// fidelity in one place for both conversion directions.
182fn convert_column(
183    column: u32,
184    line_text: Option<&str>,
185    from: PositionEncoding,
186    to: PositionEncoding,
187) -> Converted<u32> {
188    if from == to || column == 0 {
189        return Converted::exact(column);
190    }
191    let Some(text) = line_text else {
192        return Converted::passed_through(column);
193    };
194
195    let line_len_in = |encoding| {
196        EncodingConverter::new(encoding)
197            .byte_offset_to_character(text, text.len())
198            .ok()
199    };
200    // Code units never outnumber bytes, so `column > text.len()` is past the end in any encoding.
201    let past_end =
202        || column as usize > text.len() || line_len_in(from).is_some_and(|len| column > len);
203
204    let exact = (column as usize <= text.len())
205        .then(|| exact_byte_offset(text, column, from))
206        .flatten()
207        .and_then(|byte_offset| {
208            EncodingConverter::new(to)
209                .byte_offset_to_character(text, byte_offset)
210                .ok()
211        });
212    if let Some(converted) = exact {
213        return Converted::exact(converted);
214    }
215    if past_end()
216        && let Some(clamped) = line_len_in(to)
217    {
218        // A server column past its own line end means its text differs from ours.
219        return if from == PositionEncoding::Utf16 {
220            Converted::exact(clamped)
221        } else {
222            Converted {
223                value: clamped,
224                fidelity: ColumnFidelity::PassedThrough,
225            }
226        };
227    }
228    Converted::passed_through(column)
229}
230
231/// Resolve `character_offset` (in `encoding`'s units) to a byte offset in
232/// `text`, requiring the mapping to be exact.
233///
234/// `EncodingConverter::character_to_byte_offset` finds the byte boundary at
235/// or after the requested offset, so an offset that lands inside a
236/// multi-unit character (e.g. a UTF-16 surrogate pair) silently resolves to
237/// the *next* character boundary instead of erroring. Round-tripping the
238/// result back through `byte_offset_to_character` detects that case: if it
239/// doesn't reproduce `character_offset` exactly, the offset wasn't
240/// representable, and `None` signals the caller to fall back to the raw
241/// value rather than use a rounded-forward position.
242fn exact_byte_offset(
243    text: &str,
244    character_offset: u32,
245    encoding: PositionEncoding,
246) -> Option<usize> {
247    let converter = EncodingConverter::new(encoding);
248    let byte_offset = converter
249        .character_to_byte_offset(text, character_offset)
250        .ok()?;
251    let round_trip = converter.byte_offset_to_character(text, byte_offset).ok()?;
252    (round_trip == character_offset).then_some(byte_offset)
253}
254
255/// Byte offsets for a chosen set of code-unit offsets into one label,
256/// resolved in a single pass over the label.
257///
258/// Resolves signature-help parameter labels given as offset pairs
259/// (`ParameterInformationLabel::Tuple`) in the negotiated encoding's units.
260/// Per-pair scans would cost O(label) each, which a server naming many
261/// parameters could amplify; a full per-unit table would cost memory
262/// proportional to the label. This keeps one entry per distinct requested
263/// offset.
264#[derive(Debug)]
265pub struct LabelOffsets<'a> {
266    label: &'a str,
267    /// `(unit offset, byte offset)` sorted by unit; the byte offset is `None`
268    /// for an offset out of range or inside a multi-unit character.
269    resolved: Vec<(u32, Option<usize>)>,
270}
271
272impl<'a> LabelOffsets<'a> {
273    /// Resolves each of `offsets` (code units in `encoding`) against `label`.
274    #[must_use]
275    pub fn new(
276        label: &'a str,
277        offsets: impl IntoIterator<Item = u32>,
278        encoding: PositionEncoding,
279    ) -> Self {
280        let mut wanted: Vec<u32> = offsets.into_iter().collect();
281        wanted.sort_unstable();
282        wanted.dedup();
283
284        let mut resolved = Vec::with_capacity(wanted.len());
285        let mut next = 0;
286        let mut settle = |unit: usize, byte: usize| {
287            while let Some(&offset) = wanted.get(next) {
288                if offset as usize > unit {
289                    break;
290                }
291                resolved.push((offset, (offset as usize == unit).then_some(byte)));
292                next = next.saturating_add(1);
293            }
294        };
295        let mut unit = 0;
296        for (byte, ch) in label.char_indices() {
297            settle(unit, byte);
298            unit = unit.saturating_add(match encoding {
299                PositionEncoding::Utf8 => ch.len_utf8(),
300                PositionEncoding::Utf16 => ch.len_utf16(),
301                PositionEncoding::Utf32 => 1,
302            });
303        }
304        settle(unit, label.len());
305        resolved.extend(
306            wanted
307                .get(next..)
308                .unwrap_or_default()
309                .iter()
310                .map(|&offset| (offset, None)),
311        );
312        Self { label, resolved }
313    }
314
315    /// The substring of the indexed label between
316    /// `start` (inclusive) and `end` (exclusive) code units. `None` when
317    /// either offset was not among those requested, is out of range or inside
318    /// a multi-unit character, or `start > end`.
319    #[must_use]
320    pub fn substring(&self, start: u32, end: u32) -> Option<&'a str> {
321        let at = |unit: u32| {
322            let index = self
323                .resolved
324                .binary_search_by_key(&unit, |&(offset, _)| offset)
325                .ok()?;
326            self.resolved.get(index)?.1
327        };
328        self.label.get(at(start)?..at(end)?)
329    }
330}
331
332/// Position encoding converter for handling UTF-8/UTF-16/UTF-32 conversions.
333///
334/// Different LSP servers may use different character encodings. This converter
335/// handles the conversion between byte offsets and character offsets based on
336/// the negotiated encoding.
337#[derive(Debug, Clone)]
338pub struct EncodingConverter {
339    encoding: PositionEncoding,
340}
341
342impl EncodingConverter {
343    /// Create a new encoding converter with the specified encoding.
344    #[must_use]
345    pub const fn new(encoding: PositionEncoding) -> Self {
346        Self { encoding }
347    }
348
349    /// Convert byte offset to character offset in the configured encoding.
350    ///
351    /// # Errors
352    ///
353    /// Returns an error if:
354    /// - The byte offset is not on a character boundary
355    /// - The encoding is unsupported
356    #[expect(
357        clippy::cast_possible_truncation,
358        reason = "LSP positions are u32; truncation is acceptable"
359    )]
360    pub fn byte_offset_to_character(&self, text: &str, byte_offset: usize) -> Result<u32, String> {
361        if byte_offset > text.len() {
362            let text_len = text.len();
363            return Err(format!(
364                "Byte offset {byte_offset} exceeds text length {text_len}"
365            ));
366        }
367        // `text[..byte_offset]` below panics if `byte_offset` lands mid-character. A
368        // server-reported offset should always be on a boundary, but this is
369        // untrusted external input, so it is checked rather than trusted.
370        if !text.is_char_boundary(byte_offset) {
371            return Err(format!(
372                "Byte offset {byte_offset} is not on a character boundary"
373            ));
374        }
375
376        match self.encoding {
377            PositionEncoding::Utf8 => Ok(byte_offset as u32),
378            PositionEncoding::Utf16 => {
379                let utf16_units = text[..byte_offset].encode_utf16().count();
380                Ok(utf16_units as u32)
381            }
382            PositionEncoding::Utf32 => {
383                let code_points = text[..byte_offset].chars().count();
384                Ok(code_points as u32)
385            }
386        }
387    }
388
389    /// Convert character offset to byte offset in the configured encoding.
390    ///
391    /// # Errors
392    ///
393    /// Returns an error if:
394    /// - The character offset is out of bounds
395    /// - The encoding is unsupported
396    #[expect(
397        clippy::cast_possible_truncation,
398        reason = "LSP positions are u32; truncation is acceptable"
399    )]
400    pub fn character_to_byte_offset(
401        &self,
402        text: &str,
403        character_offset: u32,
404    ) -> Result<usize, String> {
405        match self.encoding {
406            PositionEncoding::Utf8 => {
407                let byte_offset = character_offset as usize;
408                if byte_offset > text.len() {
409                    let text_len = text.len();
410                    return Err(format!(
411                        "Character offset {character_offset} exceeds text length {text_len}"
412                    ));
413                }
414                // A UTF-8 "character offset" *is* a byte offset, taken
415                // directly from untrusted input (an LSP position from the
416                // server, or a re-derived offset from another encoding). It
417                // must land on a boundary before any caller slices `text`
418                // with it -- see `byte_offset_to_character`'s matching guard.
419                if !text.is_char_boundary(byte_offset) {
420                    return Err(format!(
421                        "Character offset {character_offset} is not on a character boundary"
422                    ));
423                }
424                Ok(byte_offset)
425            }
426            PositionEncoding::Utf16 => {
427                let mut utf16_count = 0u32;
428                for (byte_idx, ch) in text.char_indices() {
429                    if utf16_count >= character_offset {
430                        return Ok(byte_idx);
431                    }
432                    utf16_count = utf16_count.saturating_add(ch.len_utf16() as u32);
433                }
434                if utf16_count == character_offset {
435                    Ok(text.len())
436                } else {
437                    Err(format!(
438                        "Character offset {character_offset} out of bounds (max UTF-16 units: {utf16_count})"
439                    ))
440                }
441            }
442            PositionEncoding::Utf32 => text
443                .char_indices()
444                .nth(character_offset as usize)
445                .map(|(byte_idx, _)| byte_idx)
446                .or_else(|| {
447                    if character_offset == text.chars().count() as u32 {
448                        Some(text.len())
449                    } else {
450                        None
451                    }
452                })
453                .ok_or_else(|| {
454                    let max_code_points = text.chars().count();
455                    format!(
456                        "Character offset {character_offset} out of bounds (max code points: {max_code_points})"
457                    )
458                }),
459        }
460    }
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_string_kind_and_enum_round_trip_for_every_encoding() {
469        for encoding in PositionEncoding::ALL {
470            let kind = encoding.to_lsp();
471            assert_eq!(PositionEncoding::try_from(kind.to_string()), Ok(encoding));
472            assert_eq!(<&str>::from(encoding), kind);
473            assert_eq!(encoding.to_kind().as_str(), kind);
474        }
475    }
476
477    #[test]
478    fn test_invalid_encoding_error_lists_valid_values() {
479        let err = PositionEncoding::try_from("utf-7".to_string())
480            .unwrap_err()
481            .to_string();
482        assert!(err.contains("utf-7"));
483        for encoding in PositionEncoding::ALL {
484            assert!(err.contains(encoding.to_lsp()), "{err}");
485        }
486    }
487
488    fn label_substring(
489        label: &str,
490        start: u32,
491        end: u32,
492        encoding: PositionEncoding,
493    ) -> Option<&str> {
494        LabelOffsets::new(label, [start, end], encoding).substring(start, end)
495    }
496
497    #[test]
498    fn test_label_substring_resolves_offsets_per_encoding() {
499        let label = "é𝄞xy";
500        for (encoding, start, end) in [
501            (PositionEncoding::Utf8, 6, 8),
502            (PositionEncoding::Utf16, 3, 5),
503            (PositionEncoding::Utf32, 2, 4),
504        ] {
505            assert_eq!(
506                label_substring(label, start, end, encoding),
507                Some("xy"),
508                "{encoding:?}"
509            );
510            assert_eq!(label_substring(label, 0, 0, encoding), Some(""));
511        }
512    }
513
514    #[test]
515    fn test_label_substring_rejects_invalid_offsets() {
516        let label = "é𝄞x";
517        assert_eq!(label_substring(label, 0, 99, PositionEncoding::Utf16), None);
518        assert_eq!(label_substring(label, 0, 99, PositionEncoding::Utf8), None);
519        assert_eq!(label_substring(label, 3, 1, PositionEncoding::Utf16), None);
520        assert_eq!(label_substring(label, 4, 3, PositionEncoding::Utf8), None);
521        // Mid-character: inside `é` (UTF-8) and inside the surrogate pair (UTF-16).
522        assert_eq!(label_substring(label, 1, 2, PositionEncoding::Utf8), None);
523        assert_eq!(label_substring(label, 0, 2, PositionEncoding::Utf16), None);
524    }
525
526    fn lsp_at(
527        line: u32,
528        character: u32,
529        line_text: Option<&str>,
530        encoding: PositionEncoding,
531    ) -> Converted<lsp_types::Position> {
532        mcp_to_lsp_position(Position::at(line, character), line_text, encoding)
533    }
534
535    fn mcp_at(
536        line: u32,
537        character: u32,
538        line_text: Option<&str>,
539        encoding: PositionEncoding,
540    ) -> Converted<(u32, u32)> {
541        let converted =
542            lsp_to_mcp_position(lsp_types::Position { line, character }, line_text, encoding);
543        Converted {
544            value: (converted.value.line, converted.value.character),
545            fidelity: converted.fidelity,
546        }
547    }
548
549    #[test]
550    fn test_mcp_to_lsp_position() {
551        let lsp_pos = lsp_at(1, 1, None, PositionEncoding::Utf16).value;
552        assert_eq!(lsp_pos.line, 0);
553        assert_eq!(lsp_pos.character, 0);
554
555        let lsp_pos = lsp_at(10, 5, None, PositionEncoding::Utf16).value;
556        assert_eq!(lsp_pos.line, 9);
557        assert_eq!(lsp_pos.character, 4);
558    }
559
560    #[test]
561    fn test_lsp_to_mcp_position() {
562        let (line, char) = mcp_at(0, 0, None, PositionEncoding::Utf16).value;
563        assert_eq!(line, 1);
564        assert_eq!(char, 1);
565
566        let (line, char) = mcp_at(9, 4, None, PositionEncoding::Utf16).value;
567        assert_eq!(line, 10);
568        assert_eq!(char, 5);
569    }
570
571    #[test]
572    fn test_roundtrip() {
573        for line in 1..100 {
574            for char in 1..100 {
575                let lsp_pos = lsp_at(line, char, None, PositionEncoding::Utf16).value;
576                let Position2D {
577                    line: mcp_line,
578                    character: mcp_char,
579                } = lsp_to_mcp_position(lsp_pos, None, PositionEncoding::Utf16).value;
580                assert_eq!(line, mcp_line);
581                assert_eq!(char, mcp_char);
582            }
583        }
584    }
585
586    /// Requirement: UTF-16 negotiated encoding must be byte-for-byte
587    /// identical to the pre-negotiation behavior, even when `line_text` is
588    /// supplied and contains multi-byte characters -- the fast path must
589    /// never consult it.
590    #[test]
591    fn test_utf16_negotiated_ignores_line_text() {
592        let line_text = "let 😀 = \"héllo\";";
593        let lsp_pos = lsp_at(1, 6, Some(line_text), PositionEncoding::Utf16).value;
594        assert_eq!(lsp_pos.character, 5);
595
596        let (_, mcp_char) = mcp_at(0, 5, Some(line_text), PositionEncoding::Utf16).value;
597        assert_eq!(mcp_char, 6);
598    }
599
600    /// A UTF-8 negotiated server counts columns in bytes. `héllo` has one
601    /// multi-byte character (`é`, 2 bytes in UTF-8, 1 UTF-16 unit): the MCP
602    /// (UTF-16) column after `é` must be re-derived as one byte further in
603    /// UTF-8 terms.
604    #[test]
605    fn test_mcp_to_lsp_position_utf8_negotiated_multibyte() {
606        let line_text = "héllo";
607        // 1-based MCP column 3 sits right after "hé" (2 UTF-16 units).
608        let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
609        // In UTF-8 bytes, "hé" is 3 bytes (h=1, é=2).
610        assert_eq!(lsp_pos.character, 3);
611    }
612
613    #[test]
614    fn test_lsp_to_mcp_position_utf8_negotiated_multibyte() {
615        let line_text = "héllo";
616        // LSP (UTF-8 byte) position 3 = right after "hé".
617        let (_, mcp_char) = mcp_at(0, 3, Some(line_text), PositionEncoding::Utf8).value;
618        // In UTF-16 units, "hé" is 2 units (h=1, é=1).
619        assert_eq!(mcp_char, 3);
620    }
621
622    #[test]
623    fn test_mcp_to_lsp_position_ascii_identical_across_encodings() {
624        let line_text = "let x = 5;";
625        for encoding in [
626            PositionEncoding::Utf8,
627            PositionEncoding::Utf16,
628            PositionEncoding::Utf32,
629        ] {
630            let pos = lsp_at(1, 5, Some(line_text), encoding).value;
631            assert_eq!(
632                pos.character, 4,
633                "encoding {encoding:?} must agree on ASCII"
634            );
635        }
636    }
637
638    /// An out-of-bounds MCP character (e.g. stale client-side coordinates)
639    /// clamps to the line length in the *target* encoding's units (LSP 3.17)
640    /// rather than erroring or passing through: `"éééé"` is 4 UTF-16 units
641    /// but 8 bytes.
642    #[test]
643    fn test_mcp_to_lsp_position_past_end_of_line_clamps_exactly() {
644        for (text, column, expected) in [("short", 1000, 5), ("éééé", 6, 8)] {
645            let pos = lsp_at(1, column, Some(text), PositionEncoding::Utf8);
646            assert_eq!(pos.value.character, expected, "{text}");
647            assert_eq!(pos.fidelity, ColumnFidelity::Exact, "{text}");
648        }
649    }
650
651    #[test]
652    fn test_lsp_to_mcp_position_past_end_of_line_clamps_but_is_flagged_as_stale_text() {
653        let pos = mcp_at(0, 100, Some("éééé"), PositionEncoding::Utf8);
654        assert_eq!(pos.value, (1, 5));
655        assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
656    }
657
658    #[test]
659    fn test_mcp_to_lsp_position_missing_line_text_passes_through_flagged() {
660        let pos = lsp_at(1, 4, None, PositionEncoding::Utf8);
661        assert_eq!(pos.value.character, 3);
662        assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
663    }
664
665    #[test]
666    fn test_column_zero_is_exact_without_line_text_in_both_directions() {
667        let to_lsp = lsp_at(3, 1, None, PositionEncoding::Utf8);
668        assert_eq!(to_lsp.value.character, 0);
669        assert_eq!(to_lsp.fidelity, ColumnFidelity::Exact);
670
671        let to_mcp = mcp_at(2, 0, None, PositionEncoding::Utf32);
672        assert_eq!(to_mcp.value, (3, 1));
673        assert_eq!(to_mcp.fidelity, ColumnFidelity::Exact);
674    }
675
676    #[test]
677    fn test_lsp_to_mcp_position_missing_line_text_passes_through_flagged() {
678        let pos = mcp_at(0, 4, None, PositionEncoding::Utf8);
679        assert_eq!(pos.value, (1, 5));
680        assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
681    }
682
683    #[test]
684    fn test_utf16_is_always_exact() {
685        let pos = lsp_at(1, 500, Some("x"), PositionEncoding::Utf16);
686        assert_eq!(pos.value.character, 499);
687        assert_eq!(pos.fidelity, ColumnFidelity::Exact);
688    }
689
690    #[test]
691    fn test_position_encoding_parsing() {
692        assert_eq!(
693            PositionEncoding::from_lsp("utf-8"),
694            Some(PositionEncoding::Utf8)
695        );
696        assert_eq!(
697            PositionEncoding::from_lsp("utf-16"),
698            Some(PositionEncoding::Utf16)
699        );
700        assert_eq!(
701            PositionEncoding::from_lsp("utf-32"),
702            Some(PositionEncoding::Utf32)
703        );
704        assert_eq!(PositionEncoding::from_lsp("invalid"), None);
705    }
706
707    #[test]
708    fn test_utf8_encoding() {
709        let converter = EncodingConverter::new(PositionEncoding::Utf8);
710        let text = "Hello, world!";
711
712        let char_offset = converter.byte_offset_to_character(text, 7).unwrap();
713        assert_eq!(char_offset, 7);
714
715        let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
716        assert_eq!(byte_offset, 7);
717    }
718
719    #[test]
720    fn test_utf16_encoding_with_emoji() {
721        let converter = EncodingConverter::new(PositionEncoding::Utf16);
722        let text = "Hello 😀 world";
723
724        let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
725        assert_eq!(char_offset, 6);
726
727        let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
728        assert_eq!(char_offset, 8);
729
730        let byte_offset = converter.character_to_byte_offset(text, 6).unwrap();
731        assert_eq!(byte_offset, 6);
732
733        let byte_offset = converter.character_to_byte_offset(text, 8).unwrap();
734        assert_eq!(byte_offset, 10);
735    }
736
737    #[test]
738    fn test_utf16_encoding_roundtrip() {
739        let converter = EncodingConverter::new(PositionEncoding::Utf16);
740        let text = "Hello 🌍 world!";
741
742        for byte_idx in [0, 6, 10, 11] {
743            let char_offset = converter.byte_offset_to_character(text, byte_idx).unwrap();
744            let back_to_byte = converter
745                .character_to_byte_offset(text, char_offset)
746                .unwrap();
747            assert_eq!(byte_idx, back_to_byte);
748        }
749    }
750
751    #[test]
752    fn test_utf32_encoding() {
753        let converter = EncodingConverter::new(PositionEncoding::Utf32);
754        let text = "Hello 😀 world";
755
756        let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
757        assert_eq!(char_offset, 6);
758
759        let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
760        assert_eq!(char_offset, 7);
761
762        let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
763        assert_eq!(byte_offset, 10);
764    }
765
766    #[test]
767    fn test_encoding_edge_cases() {
768        let converter = EncodingConverter::new(PositionEncoding::Utf8);
769
770        assert!(converter.byte_offset_to_character("test", 100).is_err());
771        assert!(converter.character_to_byte_offset("test", 100).is_err());
772
773        let end_offset = converter.byte_offset_to_character("test", 4).unwrap();
774        assert_eq!(end_offset, 4);
775    }
776
777    /// C1 regression: a byte offset that lands mid-character must error, not
778    /// panic. `"héllo"` encodes `é` as the 2 bytes `0xC3 0xA9`; byte offset 2
779    /// sits between them. Before the boundary guard this reached
780    /// `text[..2].encode_utf16().count()` and panicked.
781    #[test]
782    fn test_byte_offset_to_character_mid_char_boundary_does_not_panic() {
783        let text = "héllo";
784        let byte_offset = 2; // inside 'é', not on a char boundary
785
786        for encoding in [
787            PositionEncoding::Utf8,
788            PositionEncoding::Utf16,
789            PositionEncoding::Utf32,
790        ] {
791            let converter = EncodingConverter::new(encoding);
792            assert!(
793                converter
794                    .byte_offset_to_character(text, byte_offset)
795                    .is_err(),
796                "encoding {encoding:?} must reject a mid-character byte offset instead of panicking"
797            );
798        }
799    }
800
801    /// C1 regression, `mcp_to_lsp_position`/`lsp_to_mcp_position` level: a
802    /// UTF-8-negotiated conversion whose intermediate byte offset lands
803    /// mid-character must fall back to the raw value, flagged, rather than
804    /// propagate a panic.
805    #[test]
806    fn test_lsp_to_mcp_position_utf8_mid_char_lsp_offset_falls_back() {
807        let line_text = "héllo";
808        let converted = mcp_at(0, 2, Some(line_text), PositionEncoding::Utf8);
809        assert_eq!(converted.value.1, 3); // pos.character + 1, the raw fallback
810        assert_eq!(converted.fidelity, ColumnFidelity::PassedThrough);
811    }
812
813    /// Astral (non-BMP) characters on the UTF-8 negotiated path: `𝄞` (U+1D11E,
814    /// the musical G-clef) is 4 bytes in UTF-8 and 2 UTF-16 code units (a
815    /// surrogate pair).
816    #[test]
817    fn test_mcp_to_lsp_position_utf8_negotiated_astral_char() {
818        let line_text = "𝄞x";
819        // 1-based MCP column 3 sits right after the surrogate pair (2 UTF-16
820        // units) + 1 for 1-based indexing.
821        let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
822        assert_eq!(lsp_pos.character, 4); // 4 UTF-8 bytes for the astral char
823
824        let (_, mcp_char) = mcp_at(0, 4, Some(line_text), PositionEncoding::Utf8).value;
825        assert_eq!(mcp_char, 3);
826    }
827
828    /// Copilot review finding: an MCP character offset landing inside a
829    /// UTF-16 surrogate pair (e.g. a client miscounting an astral character)
830    /// must fall back to the raw offset, not silently round forward to the
831    /// byte offset *after* the whole character. `𝄞` (U+1D11E) is a surrogate
832    /// pair (2 UTF-16 units); MCP column 2 (1-based) sits between them.
833    #[test]
834    fn test_mcp_to_lsp_position_mid_surrogate_falls_back() {
835        let line_text = "𝄞x";
836        let lsp_pos = lsp_at(1, 2, Some(line_text), PositionEncoding::Utf8);
837        // Falls back to the raw (unconverted) MCP character rather than
838        // rounding forward to byte offset 4 (right after the astral char).
839        assert_eq!(lsp_pos.value.character, 1);
840        assert_eq!(lsp_pos.fidelity, ColumnFidelity::PassedThrough);
841    }
842
843    /// CRLF line endings: `line_text` (as sourced by callers via `str::lines`)
844    /// never includes the terminator, so conversion math is identical to the
845    /// LF case -- this locks in that CRLF content doesn't shift columns.
846    #[test]
847    fn test_mcp_to_lsp_position_utf8_negotiated_crlf_line_text() {
848        let line_text = "héllo"; // as it would be yielded by "héllo\r\n".lines()
849        let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
850        assert_eq!(lsp_pos.character, 3);
851    }
852
853    /// Issue #413: LSP 3.17 permits `character: u32::MAX` as an idiom for
854    /// "end of line". A plain `+ 1` on that value overflows; `saturating_add`
855    /// must clamp to `u32::MAX` instead of panicking or wrapping to 0.
856    #[test]
857    fn test_lsp_to_mcp_position_character_max_does_not_overflow() {
858        let (_, mcp_char) = mcp_at(0, u32::MAX, None, PositionEncoding::Utf16).value;
859        assert_eq!(mcp_char, u32::MAX);
860    }
861
862    /// The `u32::MAX` end-of-line sentinel is exact in every encoding, with
863    /// or without line text.
864    #[test]
865    fn test_lsp_to_mcp_position_character_max_is_exact_for_non_utf16() {
866        for line_text in [None, Some("héllo")] {
867            let converted = mcp_at(0, u32::MAX, line_text, PositionEncoding::Utf8);
868            assert_eq!(converted.value.1, u32::MAX);
869            assert_eq!(converted.fidelity, ColumnFidelity::Exact);
870        }
871    }
872
873    /// Issue #413: same overflow hazard on `pos.line`, since it also comes
874    /// directly from the untrusted LSP server.
875    #[test]
876    fn test_lsp_to_mcp_position_line_max_does_not_overflow() {
877        let (mcp_line, _) = mcp_at(u32::MAX, 0, None, PositionEncoding::Utf16).value;
878        assert_eq!(mcp_line, u32::MAX);
879    }
880
881    /// Issue #413: both components at `u32::MAX` simultaneously.
882    #[test]
883    fn test_lsp_to_mcp_position_both_max_does_not_overflow() {
884        let (mcp_line, mcp_char) = mcp_at(u32::MAX, u32::MAX, None, PositionEncoding::Utf16).value;
885        assert_eq!(mcp_line, u32::MAX);
886        assert_eq!(mcp_char, u32::MAX);
887    }
888}