use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use lsp_types::{
InitializeParams, Position, PositionEncodingKind, TextDocumentContentChangeEvent,
TextDocumentItem, Uri,
};
use ropey::Rope;
use crate::uri_key::UriKey;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PositionEncoding {
Utf8,
#[default]
Utf16,
}
#[derive(Debug, Clone)]
pub struct Document {
uri: Uri,
language_id: String,
version: Option<i32>,
text: Rope,
}
impl Document {
pub fn uri(&self) -> &Uri {
&self.uri
}
pub fn language_id(&self) -> &str {
&self.language_id
}
pub fn version(&self) -> Option<i32> {
self.version
}
pub(crate) fn provider_snapshot(uri: Uri, text: String) -> Self {
Self {
uri,
language_id: String::new(),
version: None,
text: Rope::from_str(&text),
}
}
pub fn text(&self) -> String {
self.text.to_string()
}
pub fn position_to_offset(
&self,
encoding: PositionEncoding,
position: Position,
) -> Option<usize> {
let line_idx = position.line as usize;
if line_idx >= self.text.len_lines() {
return None;
}
let line_start_byte = self.text.line_to_byte(line_idx);
let line_text: String = self.text.line(line_idx).into();
match encoding {
PositionEncoding::Utf8 => {
let byte_in_line = position.character as usize;
let content_len = line_text.trim_end_matches(['\r', '\n']).len();
if byte_in_line > content_len || !line_text.is_char_boundary(byte_in_line) {
return None;
}
Some(line_start_byte + byte_in_line)
}
PositionEncoding::Utf16 => {
let mut utf16_count = 0usize;
for (byte_idx, ch) in line_text.char_indices() {
if utf16_count == position.character as usize {
return Some(line_start_byte + byte_idx);
}
utf16_count += ch.len_utf16();
}
if utf16_count == position.character as usize {
return Some(line_start_byte + line_text.len());
}
None
}
}
}
pub fn offset_to_position(
&self,
encoding: PositionEncoding,
offset: usize,
) -> Option<Position> {
if offset > self.text.len_bytes() {
return None;
}
let line_idx = self.text.byte_to_line(offset);
let line_start_byte = self.text.line_to_byte(line_idx);
let line_offset = offset - line_start_byte;
let line_text: String = self.text.line(line_idx).into();
match encoding {
PositionEncoding::Utf8 => Some(Position {
line: line_idx as u32,
character: line_offset as u32,
}),
PositionEncoding::Utf16 => {
let mut utf16_count = 0usize;
for (byte_idx, ch) in line_text.char_indices() {
if byte_idx == line_offset {
return Some(Position {
line: line_idx as u32,
character: utf16_count as u32,
});
}
utf16_count += ch.len_utf16();
}
Some(Position {
line: line_idx as u32,
character: utf16_count as u32,
})
}
}
}
fn apply_change(
&mut self,
encoding: PositionEncoding,
change: TextDocumentContentChangeEvent,
) -> std::result::Result<(), crate::LspError> {
let Some(range) = change.range else {
self.text = Rope::from_str(&change.text);
return Ok(());
};
let start_offset = self
.position_to_offset(encoding, range.start)
.ok_or_else(|| crate::LspError::invalid_request("invalid start position"))?;
let end_offset = self
.position_to_offset(encoding, range.end)
.ok_or_else(|| crate::LspError::invalid_request("invalid end position"))?;
if start_offset > end_offset {
return Err(crate::LspError::invalid_request(
"range end precedes range start",
));
}
let start_char = self.text.byte_to_char(start_offset);
let end_char = self.text.byte_to_char(end_offset);
self.text.remove(start_char..end_char);
self.text.insert(start_char, &change.text);
Ok(())
}
}
#[derive(Debug, Default)]
struct DocumentsInner {
by_uri: HashMap<UriKey, Document>,
encoding: PositionEncoding,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct Documents {
inner: Arc<RwLock<DocumentsInner>>,
}
impl Documents {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn open(&self, item: TextDocumentItem) {
let mut inner = self.inner.write().unwrap();
inner.by_uri.insert(
UriKey::new(&item.uri),
Document {
uri: item.uri,
language_id: item.language_id,
version: Some(item.version),
text: Rope::from_str(&item.text),
},
);
}
pub(crate) fn get(&self, uri: &Uri) -> Option<Document> {
let inner = self.inner.read().unwrap();
inner.by_uri.get(&UriKey::new(uri)).cloned()
}
pub(crate) fn close(&self, uri: &Uri) -> Option<Document> {
let mut inner = self.inner.write().unwrap();
inner.by_uri.remove(&UriKey::new(uri))
}
pub(crate) fn apply_changes(
&self,
uri: &Uri,
version: i32,
changes: impl IntoIterator<Item = TextDocumentContentChangeEvent>,
) -> std::result::Result<(), crate::LspError> {
let mut inner = self.inner.write().unwrap();
let encoding = inner.encoding;
let doc = inner
.by_uri
.get_mut(&UriKey::new(uri))
.ok_or_else(|| crate::LspError::invalid_request("document not found"))?;
let mut updated = doc.clone();
for change in changes {
updated.apply_change(encoding, change)?;
}
updated.version = Some(version);
*doc = updated;
Ok(())
}
pub(crate) fn position_to_offset(&self, uri: &Uri, position: Position) -> Option<usize> {
let inner = self.inner.read().unwrap();
inner
.by_uri
.get(&UriKey::new(uri))
.and_then(|doc| doc.position_to_offset(inner.encoding, position))
}
pub(crate) fn offset_to_position(&self, uri: &Uri, offset: usize) -> Option<Position> {
let inner = self.inner.read().unwrap();
inner
.by_uri
.get(&UriKey::new(uri))
.and_then(|doc| doc.offset_to_position(inner.encoding, offset))
}
pub(crate) fn position_encoding(&self) -> PositionEncoding {
self.inner.read().unwrap().encoding
}
fn set_position_encoding(&self, encoding: PositionEncoding) {
self.inner.write().unwrap().encoding = encoding;
}
pub(crate) fn view(&self) -> DocumentsView {
DocumentsView {
documents: self.clone(),
}
}
pub(crate) fn negotiate_position_encoding(
&self,
params: &InitializeParams,
) -> PositionEncodingKind {
let offered = params
.capabilities
.general
.as_ref()
.and_then(|g| g.position_encodings.as_deref());
let preferred = [PositionEncodingKind::UTF8, PositionEncodingKind::UTF16];
let chosen = offered
.and_then(|encodings| {
preferred
.iter()
.find(|kind| encodings.contains(kind))
.cloned()
})
.unwrap_or(PositionEncodingKind::UTF16);
self.set_position_encoding(if chosen == PositionEncodingKind::UTF8 {
PositionEncoding::Utf8
} else {
PositionEncoding::Utf16
});
chosen
}
}
#[derive(Debug, Clone)]
pub struct DocumentsView {
documents: Documents,
}
impl DocumentsView {
pub fn get(&self, uri: &Uri) -> Option<Document> {
self.documents.get(uri)
}
pub fn position_to_offset(&self, uri: &Uri, position: Position) -> Option<usize> {
self.documents.position_to_offset(uri, position)
}
pub fn offset_to_position(&self, uri: &Uri, offset: usize) -> Option<Position> {
self.documents.offset_to_position(uri, offset)
}
pub fn position_encoding(&self) -> PositionEncoding {
self.documents.position_encoding()
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use lsp_types::{GeneralClientCapabilities, Range};
use super::*;
fn uri(s: &str) -> Uri {
Uri::from_str(s).unwrap()
}
fn text_item(uri: Uri, text: &str) -> TextDocumentItem {
TextDocumentItem {
uri,
language_id: "plaintext".to_string(),
version: 1,
text: text.to_string(),
}
}
fn opened(name: &str, text: &str) -> (Documents, Uri) {
let docs = Documents::new();
let u = uri(name);
docs.open(text_item(u.clone(), text));
(docs, u)
}
fn change(range: Option<Range>, text: &str) -> TextDocumentContentChangeEvent {
TextDocumentContentChangeEvent {
range,
range_length: None,
text: text.to_string(),
}
}
fn range(start: u32, end: u32) -> Range {
Range {
start: Position {
line: 0,
character: start,
},
end: Position {
line: 0,
character: end,
},
}
}
fn at(line: u32, character: u32) -> Position {
Position { line, character }
}
fn offering(encodings: Vec<PositionEncodingKind>) -> InitializeParams {
let mut params = InitializeParams::default();
params.capabilities.general = Some(GeneralClientCapabilities {
position_encodings: Some(encodings),
..GeneralClientCapabilities::default()
});
params
}
#[test]
fn open_document_can_be_read_back() {
let (docs, u) = opened("file:///tmp/test.txt", "hello world");
let doc = docs.get(&u).expect("document should exist");
assert_eq!(doc.uri(), &u);
assert_eq!(doc.language_id(), "plaintext");
assert_eq!(doc.version(), Some(1));
assert_eq!(doc.text(), "hello world");
}
#[test]
fn close_removes_document() {
let (docs, u) = opened("file:///close.txt", "x");
assert!(docs.get(&u).is_some());
docs.close(&u);
assert!(docs.get(&u).is_none());
}
#[test]
fn equivalent_uri_spellings_resolve_to_one_document() {
let docs = Documents::new();
let original = uri("file:///C%3A/Users/Me/a.rs");
docs.open(text_item(original.clone(), "fn main() {}"));
for spelling in [
"file:///c:/Users/Me/a.rs",
"file:///C:/Users/Me/a.rs",
"file:///c%3A/Users/Me/a.rs",
"file:///c%3a/Users/Me/a.rs",
"FILE:///C:/Users/Me/a.rs",
] {
let doc = docs
.get(&uri(spelling))
.unwrap_or_else(|| panic!("{spelling} resolves to the opened document"));
assert_eq!(
doc.uri(),
&original,
"the public value keeps the URI the client opened with"
);
}
}
#[test]
fn path_case_still_distinguishes_documents() {
let docs = Documents::new();
docs.open(text_item(uri("file:///home/Foo.rs"), "a"));
assert!(
docs.get(&uri("file:///home/foo.rs")).is_none(),
"ordinary path case is not normalized"
);
}
#[test]
fn change_and_close_resolve_through_the_same_normalized_key() {
let docs = Documents::new();
docs.open(text_item(uri("file:///C%3A/w/a.rs"), "hello"));
docs.apply_changes(&uri("file:///c:/w/a.rs"), 2, [change(None, "goodbye")])
.expect("the change names the same document by another spelling");
assert_eq!(
docs.get(&uri("FILE:///C:/w/a.rs")).unwrap().text(),
"goodbye"
);
assert!(
docs.close(&uri("file:///c%3A/w/a.rs")).is_some(),
"the close names the same document by another spelling"
);
assert!(docs.get(&uri("file:///c:/w/a.rs")).is_none());
}
#[test]
fn documents_is_cheap_to_clone() {
let docs = Documents::new();
let docs2 = docs.clone();
let u = uri("file:///shared.txt");
docs.open(text_item(u.clone(), "shared"));
assert_eq!(docs2.get(&u).unwrap().text(), "shared");
}
#[test]
fn separate_document_stores_keep_same_uri_overlays_isolated() {
let first = Documents::new();
let second = Documents::new();
let u = uri("file:///shared.txt");
first.open(text_item(u.clone(), "first editor"));
second.open(text_item(u.clone(), "second editor"));
first
.apply_changes(&u, 2, [change(None, "first editor changed")])
.expect("the first editor changes its own overlay");
second.close(&u);
let remaining = first
.get(&u)
.expect("closing the second overlay is isolated");
assert_eq!(remaining.text(), "first editor changed");
assert_eq!(remaining.version(), Some(2));
assert!(second.get(&u).is_none());
}
#[test]
fn position_encoding_defaults_to_utf16() {
assert_eq!(
Documents::new().position_encoding(),
PositionEncoding::Utf16
);
}
#[test]
fn utf16_position_to_offset_counts_code_units() {
let (docs, u) = opened("file:///unicode.txt", "héllo\nworld");
assert_eq!(docs.position_to_offset(&u, at(0, 1)), Some(1));
assert_eq!(docs.position_to_offset(&u, at(1, 0)), Some(7));
}
#[test]
fn utf16_offset_to_position_round_trips() {
let (docs, u) = opened("file:///unicode.txt", "héllo\nworld");
assert_eq!(docs.offset_to_position(&u, 1), Some(at(0, 1)));
assert_eq!(docs.offset_to_position(&u, 7), Some(at(1, 0)));
}
#[test]
fn utf8_position_is_byte_offset() {
let (docs, u) = opened("file:///unicode.txt", "héllo\nworld");
docs.set_position_encoding(PositionEncoding::Utf8);
assert_eq!(docs.position_to_offset(&u, at(0, 3)), Some(3));
assert_eq!(docs.offset_to_position(&u, 3), Some(at(0, 3)));
}
#[test]
fn utf8_position_rejects_mid_codepoint_and_past_eol() {
let (docs, u) = opened("file:///unicode.txt", "héllo\nworld");
docs.set_position_encoding(PositionEncoding::Utf8);
assert_eq!(
docs.position_to_offset(&u, at(0, 2)),
None,
"byte 2 falls inside the two-byte 'é', so it is not a boundary"
);
assert_eq!(
docs.position_to_offset(&u, at(0, 7)),
None,
"\"héllo\" is 6 bytes, so character 7 points past the line's content"
);
}
#[test]
fn emoji_counts_two_utf16_code_units() {
let (docs, u) = opened("file:///emoji.txt", "a👋b");
assert_eq!(
docs.position_to_offset(&u, at(0, 3)),
Some(5),
"character 3 is past the emoji, at the byte after its four bytes"
);
assert_eq!(docs.position_to_offset(&u, at(0, 1)), Some(1));
}
#[test]
fn invalid_utf16_edit_positions_are_rejected_without_changing_the_document() {
let (docs, u) = opened("file:///invalid-utf16.txt", "a👋b");
let invalid_ranges = [
Range {
start: at(0, 2),
end: at(0, 2),
},
Range {
start: at(0, 5),
end: at(0, 5),
},
Range {
start: at(1, 0),
end: at(1, 0),
},
];
for invalid_range in invalid_ranges {
assert!(
docs.apply_changes(&u, 2, [change(Some(invalid_range), "x")])
.is_err(),
"an invalid UTF-16 endpoint must reject the whole change"
);
let doc = docs.get(&u).expect("the rejected edit keeps the document");
assert_eq!(doc.text(), "a👋b");
assert_eq!(doc.version(), Some(1));
}
assert_eq!(
docs.position_to_offset(&u, at(0, 3)),
Some(5),
"a later valid lookup still works"
);
}
#[test]
fn utf8_and_utf16_positions_round_trip_complex_unicode_across_lines() {
let text = "äa\u{0308}錯誤😋\näa\u{0308}錯誤😋";
let (docs, u) = opened("file:///position-round-trip.txt", text);
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
docs.set_position_encoding(encoding);
let mut line_start = 0;
for (line_index, line) in text.split('\n').enumerate() {
let mut character = 0;
for (byte_in_line, ch) in line.char_indices() {
let position = at(line_index as u32, character);
let offset = line_start + byte_in_line;
assert_eq!(docs.position_to_offset(&u, position), Some(offset));
assert_eq!(docs.offset_to_position(&u, offset), Some(position));
character += match encoding {
PositionEncoding::Utf8 => ch.len_utf8() as u32,
PositionEncoding::Utf16 => ch.len_utf16() as u32,
};
}
let line_end = at(line_index as u32, character);
assert_eq!(
docs.position_to_offset(&u, line_end),
Some(line_start + line.len())
);
assert_eq!(
docs.offset_to_position(&u, line_start + line.len()),
Some(line_end)
);
line_start += line.len() + 1;
}
}
}
#[test]
fn positions_use_line_content_before_crlf_and_lf_endings() {
let (docs, u) = opened("file:///line-endings.txt", "x\r\ny\n");
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
docs.set_position_encoding(encoding);
assert_eq!(docs.position_to_offset(&u, at(0, 1)), Some(1));
assert_eq!(docs.offset_to_position(&u, 1), Some(at(0, 1)));
assert_eq!(docs.position_to_offset(&u, at(1, 0)), Some(3));
assert_eq!(docs.offset_to_position(&u, 3), Some(at(1, 0)));
assert_eq!(docs.position_to_offset(&u, at(1, 1)), Some(4));
assert_eq!(docs.position_to_offset(&u, at(2, 0)), Some(5));
}
}
#[test]
fn a_change_advances_the_version_and_replaces_the_range() {
let (docs, u) = opened("file:///change.txt", "hello world");
docs.apply_changes(&u, 2, [change(Some(range(6, 11)), "lspf")])
.expect("the change applies cleanly");
let doc = docs.get(&u).unwrap();
assert_eq!(doc.text(), "hello lspf");
assert_eq!(doc.version(), Some(2));
}
#[test]
fn successful_changes_record_no_op_and_non_monotonic_versions() {
let docs = Documents::new();
let u = uri("file:///versions.txt");
docs.open(TextDocumentItem {
uri: u.clone(),
language_id: "plaintext".to_string(),
version: 25,
text: "contents".to_string(),
});
docs.apply_changes(&u, 27, [change(None, "contents")])
.expect("a no-op replacement still records its version");
let no_op = docs.get(&u).unwrap();
assert_eq!(no_op.text(), "contents");
assert_eq!(no_op.version(), Some(27));
docs.apply_changes(&u, 7, [change(None, "new contents")])
.expect("the store accepts the client's non-monotonic version");
let regressed = docs.get(&u).unwrap();
assert_eq!(regressed.text(), "new contents");
assert_eq!(regressed.version(), Some(7));
}
#[test]
fn a_change_batch_reinterprets_later_ranges_after_unicode_multiline_edits() {
let (docs, u) = opened("file:///sequential-edits.txt", "a\nb");
let changes = [
change(
Some(Range {
start: at(0, 1),
end: at(1, 0),
}),
"\nțc",
),
change(
Some(Range {
start: at(0, 1),
end: at(1, 1),
}),
"d",
),
];
docs.apply_changes(&u, 2, changes)
.expect("each range is interpreted against the preceding edit");
let doc = docs.get(&u).unwrap();
assert_eq!(doc.text(), "adcb");
assert_eq!(doc.version(), Some(2));
}
#[test]
fn an_incremental_change_can_insert_into_an_empty_document() {
let (docs, u) = opened("file:///empty.txt", "");
docs.apply_changes(&u, 2, [change(Some(range(0, 0)), "f")])
.expect("the insertion applies at the empty document's only position");
let doc = docs.get(&u).unwrap();
assert_eq!(doc.text(), "f");
assert_eq!(doc.version(), Some(2));
}
#[test]
fn an_incremental_change_can_insert_after_a_trailing_newline() {
let (docs, u) = opened("file:///eof.txt", "first\nsecond\n");
let eof = Range {
start: at(2, 0),
end: at(2, 0),
};
docs.apply_changes(&u, 2, [change(Some(eof), "third")])
.expect("the trailing newline exposes an empty final line");
let doc = docs.get(&u).unwrap();
assert_eq!(doc.text(), "first\nsecond\nthird");
assert_eq!(doc.version(), Some(2));
}
#[test]
fn an_omitted_range_replaces_the_whole_document() {
let (docs, u) = opened("file:///change.txt", "hello");
docs.apply_changes(&u, 2, [change(None, "goodbye")])
.expect("the change applies cleanly");
let doc = docs.get(&u).unwrap();
assert_eq!(doc.text(), "goodbye");
assert_eq!(doc.version(), Some(2));
}
#[test]
fn a_reversed_range_is_rejected_without_poisoning_the_store() {
let (docs, u) = opened("file:///reversed.txt", "hello world");
assert!(
docs.apply_changes(&u, 2, [change(Some(range(11, 6)), "x")])
.is_err(),
"a reversed range is an invalid request"
);
let doc = docs.get(&u).expect("the store is still readable");
assert_eq!(doc.text(), "hello world");
assert_eq!(doc.version(), Some(1), "a rejected change advances nothing");
}
#[test]
fn a_change_to_an_unopened_document_is_rejected() {
let docs = Documents::new();
assert!(
docs.apply_changes(&uri("file:///never-opened.txt"), 2, [change(None, "x")])
.is_err(),
"a change names a document the store must already hold"
);
}
#[test]
fn negotiation_defaults_to_utf16_when_the_client_offers_nothing() {
let docs = Documents::new();
assert_eq!(
docs.negotiate_position_encoding(&InitializeParams::default()),
PositionEncodingKind::UTF16
);
assert_eq!(docs.position_encoding(), PositionEncoding::Utf16);
}
#[test]
fn negotiation_picks_utf8_when_the_client_offers_it() {
let docs = Documents::new();
assert_eq!(
docs.negotiate_position_encoding(&offering(vec![PositionEncodingKind::UTF8])),
PositionEncodingKind::UTF8
);
assert_eq!(docs.position_encoding(), PositionEncoding::Utf8);
}
#[test]
fn negotiation_falls_back_to_utf16_for_utf16_only_and_unsupported_offers() {
for offered in [PositionEncodingKind::UTF16, PositionEncodingKind::UTF32] {
let docs = Documents::new();
assert_eq!(
docs.negotiate_position_encoding(&offering(vec![offered.clone()])),
PositionEncodingKind::UTF16,
"offering only {offered:?} leaves the LSP-mandatory default"
);
assert_eq!(docs.position_encoding(), PositionEncoding::Utf16);
}
}
#[test]
fn negotiation_prefers_utf8_over_utf16_when_both_are_offered() {
let docs = Documents::new();
assert_eq!(
docs.negotiate_position_encoding(&offering(vec![
PositionEncodingKind::UTF16,
PositionEncodingKind::UTF8,
])),
PositionEncodingKind::UTF8
);
assert_eq!(docs.position_encoding(), PositionEncoding::Utf8);
}
}