use std::collections::HashMap;
use super::lsp_types::{Position, Range};
pub(super) fn is_ident_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'\''
}
pub(super) fn compute_line_offsets(text: &str) -> Vec<usize> {
let mut offsets = vec![0];
for (i, byte) in text.bytes().enumerate() {
if byte == b'\n' {
offsets.push(i + 1);
}
}
offsets
}
#[derive(Clone, Debug)]
pub struct Document {
pub uri: String,
pub version: i64,
pub content: String,
pub line_offsets: Vec<usize>,
pub cached_tokens: Vec<oxilean_parse::tokens::Token>,
pub cached_decls: Vec<oxilean_parse::Located<oxilean_parse::Decl>>,
}
impl Document {
pub fn new(uri: impl Into<String>, version: i64, content: impl Into<String>) -> Self {
let uri = uri.into();
let content = content.into();
let line_offsets = compute_line_offsets(&content);
Self {
uri,
version,
content,
line_offsets,
cached_tokens: Vec::new(),
cached_decls: Vec::new(),
}
}
pub fn update(&mut self, version: i64, content: impl Into<String>) {
self.version = version;
self.content = content.into();
self.line_offsets = compute_line_offsets(&self.content);
self.cached_tokens.clear();
self.cached_decls.clear();
}
pub fn get_line(&self, line: u32) -> Option<&str> {
let idx = line as usize;
if idx >= self.line_offsets.len() {
return None;
}
let start = self.line_offsets[idx];
let end = if idx + 1 < self.line_offsets.len() {
let e = self.line_offsets[idx + 1];
if e > 0 && self.content.as_bytes().get(e - 1) == Some(&b'\n') {
e - 1
} else {
e
}
} else {
self.content.len()
};
Some(&self.content[start..end])
}
pub fn position_to_offset(&self, pos: &Position) -> Option<usize> {
let line_idx = pos.line as usize;
if line_idx >= self.line_offsets.len() {
return None;
}
let line_start = self.line_offsets[line_idx];
let line_text = self.get_line(pos.line)?;
let mut utf16_offset = 0u32;
let mut byte_offset = 0usize;
for ch in line_text.chars() {
if utf16_offset >= pos.character {
break;
}
utf16_offset += ch.len_utf16() as u32;
byte_offset += ch.len_utf8();
}
Some(line_start + byte_offset)
}
pub fn offset_to_position(&self, offset: usize) -> Position {
let offset = offset.min(self.content.len());
let line_idx = match self.line_offsets.binary_search(&offset) {
Ok(idx) => idx,
Err(idx) => {
if idx > 0 {
idx - 1
} else {
0
}
}
};
let line_start = self.line_offsets[line_idx];
let text_slice = &self.content[line_start..offset];
let character: u32 = text_slice.chars().map(|c| c.len_utf16() as u32).sum();
Position::new(line_idx as u32, character)
}
pub fn line_count(&self) -> usize {
self.line_offsets.len()
}
pub fn word_at_position(&self, pos: &Position) -> Option<(String, Range)> {
let line_text = self.get_line(pos.line)?;
let char_idx = pos.character as usize;
if char_idx > line_text.len() {
return None;
}
let bytes = line_text.as_bytes();
let mut start = char_idx;
while start > 0 && is_ident_char(bytes[start - 1]) {
start -= 1;
}
let mut end = char_idx;
while end < bytes.len() && is_ident_char(bytes[end]) {
end += 1;
}
if start == end {
return None;
}
let word = line_text[start..end].to_string();
let range = Range::single_line(pos.line, start as u32, end as u32);
Some((word, range))
}
}
#[derive(Debug, Default)]
pub struct DocumentStore {
documents: HashMap<String, Document>,
}
impl DocumentStore {
pub fn new() -> Self {
Self {
documents: HashMap::new(),
}
}
pub fn open_document(
&mut self,
uri: impl Into<String>,
version: i64,
content: impl Into<String>,
) {
let uri = uri.into();
let doc = Document::new(uri.clone(), version, content);
self.documents.insert(uri, doc);
}
pub fn update_document(&mut self, uri: &str, version: i64, content: impl Into<String>) -> bool {
if let Some(doc) = self.documents.get_mut(uri) {
doc.update(version, content);
true
} else {
false
}
}
pub fn close_document(&mut self, uri: &str) -> bool {
self.documents.remove(uri).is_some()
}
pub fn get_document(&self, uri: &str) -> Option<&Document> {
self.documents.get(uri)
}
pub fn get_document_mut(&mut self, uri: &str) -> Option<&mut Document> {
self.documents.get_mut(uri)
}
pub fn uris(&self) -> Vec<&String> {
self.documents.keys().collect()
}
pub fn len(&self) -> usize {
self.documents.len()
}
pub fn is_empty(&self) -> bool {
self.documents.is_empty()
}
}
pub fn apply_incremental_edit(
content: &str,
start_line: u32,
start_char: u32,
end_line: u32,
end_char: u32,
new_text: &str,
) -> String {
let doc = Document::new("file:///test", 0, content);
let start_pos = Position::new(start_line, start_char);
let end_pos = Position::new(end_line, end_char);
let content_len = doc.content.len();
let start_off = doc
.position_to_offset(&start_pos)
.unwrap_or(0)
.min(content_len);
let end_off = doc
.position_to_offset(&end_pos)
.unwrap_or(content_len)
.min(content_len)
.max(start_off);
let mut result =
String::with_capacity(content_len.saturating_sub(end_off - start_off) + new_text.len());
result.push_str(&doc.content[..start_off]);
result.push_str(new_text);
result.push_str(&doc.content[end_off..]);
result
}
fn apply_full_edit(new_text: &str) -> String {
new_text.to_string()
}
#[cfg(test)]
mod incremental_sync_tests {
use super::*;
fn assert_incr_equals_full(
original: &str,
sl: u32,
sc: u32,
el: u32,
ec: u32,
new_text: &str,
expected: &str,
) {
let incr = apply_incremental_edit(original, sl, sc, el, ec, new_text);
let full = apply_full_edit(expected);
assert_eq!(
incr, full,
"incremental != expected\n original: {:?}\n range: ({},{})..({},{})\n new_text: {:?}\n got: {:?}\n want: {:?}",
original, sl, sc, el, ec, new_text, incr, full
);
}
#[test]
fn test_incr_ascii_replace_word() {
let orig = "hello world";
assert_incr_equals_full(orig, 0, 6, 0, 11, "there", "hello there");
}
#[test]
fn test_incr_two_byte_utf8() {
let orig = "café world";
assert_incr_equals_full(orig, 0, 3, 0, 4, "e", "cafe world");
}
#[test]
fn test_incr_cjk_single_char() {
let orig = "日本語";
assert_incr_equals_full(orig, 0, 1, 0, 2, "B", "日B語");
}
#[test]
fn test_incr_cjk_insert_before() {
let orig = "語";
assert_incr_equals_full(orig, 0, 0, 0, 0, "日本", "日本語");
}
#[test]
fn test_incr_emoji_surrogate_pair() {
let orig = "hi 😀 there";
assert_incr_equals_full(orig, 0, 3, 0, 5, ":)", "hi :) there");
}
#[test]
fn test_incr_multiline_insert_newline() {
let orig = "first\nsecond\nthird";
let expected = "first\ninserted\nsecond\nthird";
assert_incr_equals_full(orig, 0, 5, 0, 5, "\ninserted", expected);
}
#[test]
fn test_incr_multiline_delete() {
let orig = "alpha\nbeta\ngamma";
let expected = "alphagamma";
assert_incr_equals_full(orig, 0, 5, 2, 0, "", expected);
}
#[test]
fn test_incr_replace_at_start() {
let orig = "def foo := 42";
assert_incr_equals_full(orig, 0, 0, 0, 3, "let", "let foo := 42");
}
#[test]
fn test_incr_replace_at_end() {
let orig = "def foo := 42";
assert_incr_equals_full(orig, 0, 11, 0, 13, "100", "def foo := 100");
}
#[test]
fn test_incr_empty_range_insert() {
let orig = "ab";
assert_incr_equals_full(orig, 0, 1, 0, 1, "X", "aXb");
}
#[test]
fn test_incr_out_of_range_no_panic() {
let orig = "short";
let result = apply_incremental_edit(orig, 99, 99, 99, 99, "appended");
assert!(!result.is_empty(), "result must not be empty");
assert_eq!(result, "appended");
}
#[test]
fn test_incr_reversed_range_no_panic() {
let orig = "abcdef";
let result = apply_incremental_edit(orig, 0, 4, 0, 2, "X");
assert!(!result.is_empty(), "result must not be empty");
}
#[test]
fn test_incr_replace_whole_line() {
let orig = "line one\nline two\nline three";
assert_incr_equals_full(orig, 1, 0, 1, 8, "NEW", "line one\nNEW\nline three");
}
#[test]
fn test_incr_mixed_unicode_document() {
let orig = "hello\n日本語\n😀 world";
assert_incr_equals_full(orig, 1, 0, 1, 3, "Japanese", "hello\nJapanese\n😀 world");
}
}
#[cfg(test)]
mod position_to_char_offset_tests {
use super::*;
fn byte_to_char_index(content: &str, line: u32, character: u32) -> usize {
let doc = Document::new("file:///test", 0, content);
let pos = Position::new(line, character);
let byte_off = doc.position_to_offset(&pos).unwrap_or(0);
content[..byte_off].chars().count()
}
#[test]
fn test_position_to_char_offset_ascii() {
let content = "hello world";
assert_eq!(byte_to_char_index(content, 0, 0), 0);
assert_eq!(byte_to_char_index(content, 0, 5), 5);
assert_eq!(byte_to_char_index(content, 0, 11), 11);
}
#[test]
fn test_position_to_char_offset_two_byte_char() {
let content = "café";
assert_eq!(byte_to_char_index(content, 0, 3), 3);
assert_eq!(byte_to_char_index(content, 0, 4), 4);
}
#[test]
fn test_position_to_char_offset_cjk() {
let content = "日本語";
assert_eq!(byte_to_char_index(content, 0, 0), 0);
assert_eq!(byte_to_char_index(content, 0, 1), 1);
assert_eq!(byte_to_char_index(content, 0, 2), 2);
assert_eq!(byte_to_char_index(content, 0, 3), 3);
}
#[test]
fn test_position_to_char_offset_emoji() {
let content = "hi 😀!";
assert_eq!(byte_to_char_index(content, 0, 5), 4);
assert_eq!(byte_to_char_index(content, 0, 0), 0);
}
#[test]
fn test_position_to_char_offset_multiline() {
let content = "abc\ndef";
assert_eq!(byte_to_char_index(content, 1, 0), 4);
assert_eq!(byte_to_char_index(content, 1, 3), 7);
}
}