use std::collections::BTreeSet;
use anyhow::{Context, Result, bail};
use lsp_types::{Position, TextDocumentContentChangeEvent};
pub fn split_lines(text: &str) -> Vec<&str> {
if text.is_empty() {
Vec::new()
} else {
text.split('\n').collect()
}
}
pub fn changed_lines_between(before: &str, after: &str) -> BTreeSet<u32> {
let before_lines = split_lines(before);
let after_lines = split_lines(after);
let mut changed = BTreeSet::new();
let max_len = before_lines.len().max(after_lines.len());
for index in 0..max_len {
let before_line = before_lines.get(index);
let after_line = after_lines.get(index);
if before_line != after_line && index < after_lines.len() {
changed.insert((index + 1) as u32);
}
}
changed
}
pub fn build_synthetic_diff(path: &str, text: &str, changed_lines: &BTreeSet<u32>) -> String {
let mut diff = format!(
"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n",
path = path
);
let lines = split_lines(text);
for line_number in changed_lines {
if *line_number == 0 {
continue;
}
let index = (*line_number as usize).saturating_sub(1);
if index >= lines.len() {
continue;
}
diff.push_str(&format!("@@ -0,0 +{},1 @@\n", line_number));
diff.push('+');
diff.push_str(lines[index]);
diff.push('\n');
}
diff
}
pub fn apply_incremental_change(
text: &mut String,
change: &TextDocumentContentChangeEvent,
) -> Result<()> {
let Some(range) = change.range else {
*text = change.text.clone();
return Ok(());
};
let start = byte_offset_at_position(text, range.start).with_context(|| {
format!(
"invalid start position line={}, character={}",
range.start.line, range.start.character
)
})?;
let end = byte_offset_at_position(text, range.end).with_context(|| {
format!(
"invalid end position line={}, character={}",
range.end.line, range.end.character
)
})?;
if start > end {
bail!("invalid edit range: start {} is after end {}", start, end);
}
text.replace_range(start..end, &change.text);
Ok(())
}
pub fn byte_offset_at_position(text: &str, position: Position) -> Option<usize> {
let mut current_line: u32 = 0;
let mut current_character_utf16: u32 = 0;
for (index, ch) in text.char_indices() {
if current_line == position.line && current_character_utf16 == position.character {
return Some(index);
}
if ch == '\n' {
if current_line == position.line && current_character_utf16 == position.character {
return Some(index);
}
current_line = current_line.saturating_add(1);
current_character_utf16 = 0;
continue;
}
if current_line == position.line {
current_character_utf16 = current_character_utf16.saturating_add(ch.len_utf16() as u32);
if current_character_utf16 > position.character {
return None;
}
}
}
if current_line == position.line && current_character_utf16 == position.character {
Some(text.len())
} else {
None
}
}
pub fn utf16_length(text: &str) -> u32 {
text.chars().map(|ch| ch.len_utf16() as u32).sum()
}
#[cfg(test)]
mod tests {
use super::*;
use lsp_types::{Position, Range, TextDocumentContentChangeEvent};
#[test]
fn changed_lines_between_marks_modified_line() {
let before = "one\ntwo\nthree\n";
let after = "one\nTWO\nthree\n";
let changed = changed_lines_between(before, after);
assert_eq!(changed, BTreeSet::from([2]));
}
#[test]
fn build_synthetic_diff_emits_hunks_for_changed_lines() {
let changed = BTreeSet::from([2_u32, 3_u32]);
let diff = build_synthetic_diff("src/lib.rs", "one\ntwo\nthree\n", &changed);
assert!(diff.contains("@@ -0,0 +2,1 @@"));
assert!(diff.contains("@@ -0,0 +3,1 @@"));
assert!(diff.contains("+two"));
assert!(diff.contains("+three"));
}
#[test]
fn apply_incremental_change_replaces_range() {
let mut text = "alpha\nbeta\n".to_string();
let change = TextDocumentContentChangeEvent {
range: Some(Range::new(Position::new(1, 0), Position::new(1, 4))),
range_length: None,
text: "gamma".to_string(),
};
apply_incremental_change(&mut text, &change).expect("apply");
assert_eq!(text, "alpha\ngamma\n");
}
}