use serde::{Deserialize, Serialize};
use super::translator::{Position, Position2D};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(try_from = "String", into = "&'static str")]
pub enum PositionEncoding {
#[default]
Utf8,
Utf16,
Utf32,
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
#[error("invalid position encoding '{0}'; expected one of \"utf-8\", \"utf-16\", \"utf-32\"")]
pub struct InvalidPositionEncoding(String);
impl PositionEncoding {
pub const ALL: [Self; 3] = [Self::Utf8, Self::Utf16, Self::Utf32];
#[must_use]
pub fn from_lsp(kind: &str) -> Option<Self> {
Self::ALL.into_iter().find(|e| e.to_lsp() == kind)
}
#[must_use]
pub const fn to_lsp(&self) -> &'static str {
match self {
Self::Utf8 => "utf-8",
Self::Utf16 => "utf-16",
Self::Utf32 => "utf-32",
}
}
#[must_use]
pub const fn to_kind(self) -> lsp_types::PositionEncodingKind {
match self {
Self::Utf8 => lsp_types::PositionEncodingKind::UTF8,
Self::Utf16 => lsp_types::PositionEncodingKind::UTF16,
Self::Utf32 => lsp_types::PositionEncodingKind::UTF32,
}
}
}
impl TryFrom<String> for PositionEncoding {
type Error = InvalidPositionEncoding;
fn try_from(kind: String) -> Result<Self, Self::Error> {
Self::from_lsp(&kind).ok_or(InvalidPositionEncoding(kind))
}
}
impl From<PositionEncoding> for &'static str {
fn from(encoding: PositionEncoding) -> Self {
encoding.to_lsp()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ColumnFidelity {
Exact,
PassedThrough,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub struct Converted<T> {
pub value: T,
pub fidelity: ColumnFidelity,
}
impl<T> Converted<T> {
const fn exact(value: T) -> Self {
Self {
value,
fidelity: ColumnFidelity::Exact,
}
}
const fn passed_through(value: T) -> Self {
Self {
value,
fidelity: ColumnFidelity::PassedThrough,
}
}
}
pub fn mcp_to_lsp_position(
position: Position,
line_text: Option<&str>,
encoding: PositionEncoding,
) -> Converted<lsp_types::Position> {
let column = convert_column(
position.lsp_character(),
line_text,
PositionEncoding::Utf16,
encoding,
);
Converted {
value: lsp_types::Position {
line: position.lsp_line(),
character: column.value,
},
fidelity: column.fidelity,
}
}
pub fn lsp_to_mcp_position(
pos: lsp_types::Position,
line_text: Option<&str>,
encoding: PositionEncoding,
) -> Converted<Position2D> {
let column = if pos.character == u32::MAX {
Converted::exact(u32::MAX)
} else {
convert_column(pos.character, line_text, encoding, PositionEncoding::Utf16)
};
Converted {
value: Position2D {
line: pos.line.saturating_add(1),
character: column.value.saturating_add(1),
},
fidelity: column.fidelity,
}
}
fn convert_column(
column: u32,
line_text: Option<&str>,
from: PositionEncoding,
to: PositionEncoding,
) -> Converted<u32> {
if from == to || column == 0 {
return Converted::exact(column);
}
let Some(text) = line_text else {
return Converted::passed_through(column);
};
let line_len_in = |encoding| {
EncodingConverter::new(encoding)
.byte_offset_to_character(text, text.len())
.ok()
};
let past_end =
|| column as usize > text.len() || line_len_in(from).is_some_and(|len| column > len);
let exact = (column as usize <= text.len())
.then(|| exact_byte_offset(text, column, from))
.flatten()
.and_then(|byte_offset| {
EncodingConverter::new(to)
.byte_offset_to_character(text, byte_offset)
.ok()
});
if let Some(converted) = exact {
return Converted::exact(converted);
}
if past_end()
&& let Some(clamped) = line_len_in(to)
{
return if from == PositionEncoding::Utf16 {
Converted::exact(clamped)
} else {
Converted {
value: clamped,
fidelity: ColumnFidelity::PassedThrough,
}
};
}
Converted::passed_through(column)
}
fn exact_byte_offset(
text: &str,
character_offset: u32,
encoding: PositionEncoding,
) -> Option<usize> {
let converter = EncodingConverter::new(encoding);
let byte_offset = converter
.character_to_byte_offset(text, character_offset)
.ok()?;
let round_trip = converter.byte_offset_to_character(text, byte_offset).ok()?;
(round_trip == character_offset).then_some(byte_offset)
}
#[derive(Debug)]
pub struct LabelOffsets<'a> {
label: &'a str,
resolved: Vec<(u32, Option<usize>)>,
}
impl<'a> LabelOffsets<'a> {
#[must_use]
pub fn new(
label: &'a str,
offsets: impl IntoIterator<Item = u32>,
encoding: PositionEncoding,
) -> Self {
let mut wanted: Vec<u32> = offsets.into_iter().collect();
wanted.sort_unstable();
wanted.dedup();
let mut resolved = Vec::with_capacity(wanted.len());
let mut next = 0;
let mut settle = |unit: usize, byte: usize| {
while let Some(&offset) = wanted.get(next) {
if offset as usize > unit {
break;
}
resolved.push((offset, (offset as usize == unit).then_some(byte)));
next = next.saturating_add(1);
}
};
let mut unit = 0;
for (byte, ch) in label.char_indices() {
settle(unit, byte);
unit = unit.saturating_add(match encoding {
PositionEncoding::Utf8 => ch.len_utf8(),
PositionEncoding::Utf16 => ch.len_utf16(),
PositionEncoding::Utf32 => 1,
});
}
settle(unit, label.len());
resolved.extend(
wanted
.get(next..)
.unwrap_or_default()
.iter()
.map(|&offset| (offset, None)),
);
Self { label, resolved }
}
#[must_use]
pub fn substring(&self, start: u32, end: u32) -> Option<&'a str> {
let at = |unit: u32| {
let index = self
.resolved
.binary_search_by_key(&unit, |&(offset, _)| offset)
.ok()?;
self.resolved.get(index)?.1
};
self.label.get(at(start)?..at(end)?)
}
}
#[derive(Debug, Clone)]
pub struct EncodingConverter {
encoding: PositionEncoding,
}
impl EncodingConverter {
#[must_use]
pub const fn new(encoding: PositionEncoding) -> Self {
Self { encoding }
}
#[expect(
clippy::cast_possible_truncation,
reason = "LSP positions are u32; truncation is acceptable"
)]
pub fn byte_offset_to_character(&self, text: &str, byte_offset: usize) -> Result<u32, String> {
if byte_offset > text.len() {
let text_len = text.len();
return Err(format!(
"Byte offset {byte_offset} exceeds text length {text_len}"
));
}
if !text.is_char_boundary(byte_offset) {
return Err(format!(
"Byte offset {byte_offset} is not on a character boundary"
));
}
match self.encoding {
PositionEncoding::Utf8 => Ok(byte_offset as u32),
PositionEncoding::Utf16 => {
let utf16_units = text[..byte_offset].encode_utf16().count();
Ok(utf16_units as u32)
}
PositionEncoding::Utf32 => {
let code_points = text[..byte_offset].chars().count();
Ok(code_points as u32)
}
}
}
#[expect(
clippy::cast_possible_truncation,
reason = "LSP positions are u32; truncation is acceptable"
)]
pub fn character_to_byte_offset(
&self,
text: &str,
character_offset: u32,
) -> Result<usize, String> {
match self.encoding {
PositionEncoding::Utf8 => {
let byte_offset = character_offset as usize;
if byte_offset > text.len() {
let text_len = text.len();
return Err(format!(
"Character offset {character_offset} exceeds text length {text_len}"
));
}
if !text.is_char_boundary(byte_offset) {
return Err(format!(
"Character offset {character_offset} is not on a character boundary"
));
}
Ok(byte_offset)
}
PositionEncoding::Utf16 => {
let mut utf16_count = 0u32;
for (byte_idx, ch) in text.char_indices() {
if utf16_count >= character_offset {
return Ok(byte_idx);
}
utf16_count = utf16_count.saturating_add(ch.len_utf16() as u32);
}
if utf16_count == character_offset {
Ok(text.len())
} else {
Err(format!(
"Character offset {character_offset} out of bounds (max UTF-16 units: {utf16_count})"
))
}
}
PositionEncoding::Utf32 => text
.char_indices()
.nth(character_offset as usize)
.map(|(byte_idx, _)| byte_idx)
.or_else(|| {
if character_offset == text.chars().count() as u32 {
Some(text.len())
} else {
None
}
})
.ok_or_else(|| {
let max_code_points = text.chars().count();
format!(
"Character offset {character_offset} out of bounds (max code points: {max_code_points})"
)
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_kind_and_enum_round_trip_for_every_encoding() {
for encoding in PositionEncoding::ALL {
let kind = encoding.to_lsp();
assert_eq!(PositionEncoding::try_from(kind.to_string()), Ok(encoding));
assert_eq!(<&str>::from(encoding), kind);
assert_eq!(encoding.to_kind().as_str(), kind);
}
}
#[test]
fn test_invalid_encoding_error_lists_valid_values() {
let err = PositionEncoding::try_from("utf-7".to_string())
.unwrap_err()
.to_string();
assert!(err.contains("utf-7"));
for encoding in PositionEncoding::ALL {
assert!(err.contains(encoding.to_lsp()), "{err}");
}
}
fn label_substring(
label: &str,
start: u32,
end: u32,
encoding: PositionEncoding,
) -> Option<&str> {
LabelOffsets::new(label, [start, end], encoding).substring(start, end)
}
#[test]
fn test_label_substring_resolves_offsets_per_encoding() {
let label = "é𝄞xy";
for (encoding, start, end) in [
(PositionEncoding::Utf8, 6, 8),
(PositionEncoding::Utf16, 3, 5),
(PositionEncoding::Utf32, 2, 4),
] {
assert_eq!(
label_substring(label, start, end, encoding),
Some("xy"),
"{encoding:?}"
);
assert_eq!(label_substring(label, 0, 0, encoding), Some(""));
}
}
#[test]
fn test_label_substring_rejects_invalid_offsets() {
let label = "é𝄞x";
assert_eq!(label_substring(label, 0, 99, PositionEncoding::Utf16), None);
assert_eq!(label_substring(label, 0, 99, PositionEncoding::Utf8), None);
assert_eq!(label_substring(label, 3, 1, PositionEncoding::Utf16), None);
assert_eq!(label_substring(label, 4, 3, PositionEncoding::Utf8), None);
assert_eq!(label_substring(label, 1, 2, PositionEncoding::Utf8), None);
assert_eq!(label_substring(label, 0, 2, PositionEncoding::Utf16), None);
}
fn lsp_at(
line: u32,
character: u32,
line_text: Option<&str>,
encoding: PositionEncoding,
) -> Converted<lsp_types::Position> {
mcp_to_lsp_position(Position::at(line, character), line_text, encoding)
}
fn mcp_at(
line: u32,
character: u32,
line_text: Option<&str>,
encoding: PositionEncoding,
) -> Converted<(u32, u32)> {
let converted =
lsp_to_mcp_position(lsp_types::Position { line, character }, line_text, encoding);
Converted {
value: (converted.value.line, converted.value.character),
fidelity: converted.fidelity,
}
}
#[test]
fn test_mcp_to_lsp_position() {
let lsp_pos = lsp_at(1, 1, None, PositionEncoding::Utf16).value;
assert_eq!(lsp_pos.line, 0);
assert_eq!(lsp_pos.character, 0);
let lsp_pos = lsp_at(10, 5, None, PositionEncoding::Utf16).value;
assert_eq!(lsp_pos.line, 9);
assert_eq!(lsp_pos.character, 4);
}
#[test]
fn test_lsp_to_mcp_position() {
let (line, char) = mcp_at(0, 0, None, PositionEncoding::Utf16).value;
assert_eq!(line, 1);
assert_eq!(char, 1);
let (line, char) = mcp_at(9, 4, None, PositionEncoding::Utf16).value;
assert_eq!(line, 10);
assert_eq!(char, 5);
}
#[test]
fn test_roundtrip() {
for line in 1..100 {
for char in 1..100 {
let lsp_pos = lsp_at(line, char, None, PositionEncoding::Utf16).value;
let Position2D {
line: mcp_line,
character: mcp_char,
} = lsp_to_mcp_position(lsp_pos, None, PositionEncoding::Utf16).value;
assert_eq!(line, mcp_line);
assert_eq!(char, mcp_char);
}
}
}
#[test]
fn test_utf16_negotiated_ignores_line_text() {
let line_text = "let 😀 = \"héllo\";";
let lsp_pos = lsp_at(1, 6, Some(line_text), PositionEncoding::Utf16).value;
assert_eq!(lsp_pos.character, 5);
let (_, mcp_char) = mcp_at(0, 5, Some(line_text), PositionEncoding::Utf16).value;
assert_eq!(mcp_char, 6);
}
#[test]
fn test_mcp_to_lsp_position_utf8_negotiated_multibyte() {
let line_text = "héllo";
let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
assert_eq!(lsp_pos.character, 3);
}
#[test]
fn test_lsp_to_mcp_position_utf8_negotiated_multibyte() {
let line_text = "héllo";
let (_, mcp_char) = mcp_at(0, 3, Some(line_text), PositionEncoding::Utf8).value;
assert_eq!(mcp_char, 3);
}
#[test]
fn test_mcp_to_lsp_position_ascii_identical_across_encodings() {
let line_text = "let x = 5;";
for encoding in [
PositionEncoding::Utf8,
PositionEncoding::Utf16,
PositionEncoding::Utf32,
] {
let pos = lsp_at(1, 5, Some(line_text), encoding).value;
assert_eq!(
pos.character, 4,
"encoding {encoding:?} must agree on ASCII"
);
}
}
#[test]
fn test_mcp_to_lsp_position_past_end_of_line_clamps_exactly() {
for (text, column, expected) in [("short", 1000, 5), ("éééé", 6, 8)] {
let pos = lsp_at(1, column, Some(text), PositionEncoding::Utf8);
assert_eq!(pos.value.character, expected, "{text}");
assert_eq!(pos.fidelity, ColumnFidelity::Exact, "{text}");
}
}
#[test]
fn test_lsp_to_mcp_position_past_end_of_line_clamps_but_is_flagged_as_stale_text() {
let pos = mcp_at(0, 100, Some("éééé"), PositionEncoding::Utf8);
assert_eq!(pos.value, (1, 5));
assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
}
#[test]
fn test_mcp_to_lsp_position_missing_line_text_passes_through_flagged() {
let pos = lsp_at(1, 4, None, PositionEncoding::Utf8);
assert_eq!(pos.value.character, 3);
assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
}
#[test]
fn test_column_zero_is_exact_without_line_text_in_both_directions() {
let to_lsp = lsp_at(3, 1, None, PositionEncoding::Utf8);
assert_eq!(to_lsp.value.character, 0);
assert_eq!(to_lsp.fidelity, ColumnFidelity::Exact);
let to_mcp = mcp_at(2, 0, None, PositionEncoding::Utf32);
assert_eq!(to_mcp.value, (3, 1));
assert_eq!(to_mcp.fidelity, ColumnFidelity::Exact);
}
#[test]
fn test_lsp_to_mcp_position_missing_line_text_passes_through_flagged() {
let pos = mcp_at(0, 4, None, PositionEncoding::Utf8);
assert_eq!(pos.value, (1, 5));
assert_eq!(pos.fidelity, ColumnFidelity::PassedThrough);
}
#[test]
fn test_utf16_is_always_exact() {
let pos = lsp_at(1, 500, Some("x"), PositionEncoding::Utf16);
assert_eq!(pos.value.character, 499);
assert_eq!(pos.fidelity, ColumnFidelity::Exact);
}
#[test]
fn test_position_encoding_parsing() {
assert_eq!(
PositionEncoding::from_lsp("utf-8"),
Some(PositionEncoding::Utf8)
);
assert_eq!(
PositionEncoding::from_lsp("utf-16"),
Some(PositionEncoding::Utf16)
);
assert_eq!(
PositionEncoding::from_lsp("utf-32"),
Some(PositionEncoding::Utf32)
);
assert_eq!(PositionEncoding::from_lsp("invalid"), None);
}
#[test]
fn test_utf8_encoding() {
let converter = EncodingConverter::new(PositionEncoding::Utf8);
let text = "Hello, world!";
let char_offset = converter.byte_offset_to_character(text, 7).unwrap();
assert_eq!(char_offset, 7);
let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
assert_eq!(byte_offset, 7);
}
#[test]
fn test_utf16_encoding_with_emoji() {
let converter = EncodingConverter::new(PositionEncoding::Utf16);
let text = "Hello 😀 world";
let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
assert_eq!(char_offset, 6);
let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
assert_eq!(char_offset, 8);
let byte_offset = converter.character_to_byte_offset(text, 6).unwrap();
assert_eq!(byte_offset, 6);
let byte_offset = converter.character_to_byte_offset(text, 8).unwrap();
assert_eq!(byte_offset, 10);
}
#[test]
fn test_utf16_encoding_roundtrip() {
let converter = EncodingConverter::new(PositionEncoding::Utf16);
let text = "Hello 🌍 world!";
for byte_idx in [0, 6, 10, 11] {
let char_offset = converter.byte_offset_to_character(text, byte_idx).unwrap();
let back_to_byte = converter
.character_to_byte_offset(text, char_offset)
.unwrap();
assert_eq!(byte_idx, back_to_byte);
}
}
#[test]
fn test_utf32_encoding() {
let converter = EncodingConverter::new(PositionEncoding::Utf32);
let text = "Hello 😀 world";
let char_offset = converter.byte_offset_to_character(text, 6).unwrap();
assert_eq!(char_offset, 6);
let char_offset = converter.byte_offset_to_character(text, 10).unwrap();
assert_eq!(char_offset, 7);
let byte_offset = converter.character_to_byte_offset(text, 7).unwrap();
assert_eq!(byte_offset, 10);
}
#[test]
fn test_encoding_edge_cases() {
let converter = EncodingConverter::new(PositionEncoding::Utf8);
assert!(converter.byte_offset_to_character("test", 100).is_err());
assert!(converter.character_to_byte_offset("test", 100).is_err());
let end_offset = converter.byte_offset_to_character("test", 4).unwrap();
assert_eq!(end_offset, 4);
}
#[test]
fn test_byte_offset_to_character_mid_char_boundary_does_not_panic() {
let text = "héllo";
let byte_offset = 2;
for encoding in [
PositionEncoding::Utf8,
PositionEncoding::Utf16,
PositionEncoding::Utf32,
] {
let converter = EncodingConverter::new(encoding);
assert!(
converter
.byte_offset_to_character(text, byte_offset)
.is_err(),
"encoding {encoding:?} must reject a mid-character byte offset instead of panicking"
);
}
}
#[test]
fn test_lsp_to_mcp_position_utf8_mid_char_lsp_offset_falls_back() {
let line_text = "héllo";
let converted = mcp_at(0, 2, Some(line_text), PositionEncoding::Utf8);
assert_eq!(converted.value.1, 3); assert_eq!(converted.fidelity, ColumnFidelity::PassedThrough);
}
#[test]
fn test_mcp_to_lsp_position_utf8_negotiated_astral_char() {
let line_text = "𝄞x";
let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
assert_eq!(lsp_pos.character, 4);
let (_, mcp_char) = mcp_at(0, 4, Some(line_text), PositionEncoding::Utf8).value;
assert_eq!(mcp_char, 3);
}
#[test]
fn test_mcp_to_lsp_position_mid_surrogate_falls_back() {
let line_text = "𝄞x";
let lsp_pos = lsp_at(1, 2, Some(line_text), PositionEncoding::Utf8);
assert_eq!(lsp_pos.value.character, 1);
assert_eq!(lsp_pos.fidelity, ColumnFidelity::PassedThrough);
}
#[test]
fn test_mcp_to_lsp_position_utf8_negotiated_crlf_line_text() {
let line_text = "héllo"; let lsp_pos = lsp_at(1, 3, Some(line_text), PositionEncoding::Utf8).value;
assert_eq!(lsp_pos.character, 3);
}
#[test]
fn test_lsp_to_mcp_position_character_max_does_not_overflow() {
let (_, mcp_char) = mcp_at(0, u32::MAX, None, PositionEncoding::Utf16).value;
assert_eq!(mcp_char, u32::MAX);
}
#[test]
fn test_lsp_to_mcp_position_character_max_is_exact_for_non_utf16() {
for line_text in [None, Some("héllo")] {
let converted = mcp_at(0, u32::MAX, line_text, PositionEncoding::Utf8);
assert_eq!(converted.value.1, u32::MAX);
assert_eq!(converted.fidelity, ColumnFidelity::Exact);
}
}
#[test]
fn test_lsp_to_mcp_position_line_max_does_not_overflow() {
let (mcp_line, _) = mcp_at(u32::MAX, 0, None, PositionEncoding::Utf16).value;
assert_eq!(mcp_line, u32::MAX);
}
#[test]
fn test_lsp_to_mcp_position_both_max_does_not_overflow() {
let (mcp_line, mcp_char) = mcp_at(u32::MAX, u32::MAX, None, PositionEncoding::Utf16).value;
assert_eq!(mcp_line, u32::MAX);
assert_eq!(mcp_char, u32::MAX);
}
}