1use std::collections::BTreeSet;
2
3use anyhow::{Context, Result, bail};
4use lsp_types::{Position, TextDocumentContentChangeEvent};
5
6pub fn split_lines(text: &str) -> Vec<&str> {
7 if text.is_empty() {
8 Vec::new()
9 } else {
10 text.split('\n').collect()
11 }
12}
13
14pub fn changed_lines_between(before: &str, after: &str) -> BTreeSet<u32> {
15 let before_lines = split_lines(before);
16 let after_lines = split_lines(after);
17 let mut changed = BTreeSet::new();
18 let max_len = before_lines.len().max(after_lines.len());
19
20 for index in 0..max_len {
21 let before_line = before_lines.get(index);
22 let after_line = after_lines.get(index);
23 if before_line != after_line && index < after_lines.len() {
24 changed.insert((index + 1) as u32);
25 }
26 }
27
28 changed
29}
30
31pub fn build_synthetic_diff(path: &str, text: &str, changed_lines: &BTreeSet<u32>) -> String {
32 let mut diff = format!(
33 "diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n",
34 path = path
35 );
36 let lines = split_lines(text);
37
38 for line_number in changed_lines {
39 if *line_number == 0 {
40 continue;
41 }
42
43 let index = (*line_number as usize).saturating_sub(1);
44 if index >= lines.len() {
45 continue;
46 }
47
48 diff.push_str(&format!("@@ -0,0 +{},1 @@\n", line_number));
49 diff.push('+');
50 diff.push_str(lines[index]);
51 diff.push('\n');
52 }
53
54 diff
55}
56
57pub fn apply_incremental_change(
58 text: &mut String,
59 change: &TextDocumentContentChangeEvent,
60) -> Result<()> {
61 let Some(range) = change.range else {
62 *text = change.text.clone();
63 return Ok(());
64 };
65
66 let start = byte_offset_at_position(text, range.start).with_context(|| {
67 format!(
68 "invalid start position line={}, character={}",
69 range.start.line, range.start.character
70 )
71 })?;
72 let end = byte_offset_at_position(text, range.end).with_context(|| {
73 format!(
74 "invalid end position line={}, character={}",
75 range.end.line, range.end.character
76 )
77 })?;
78
79 if start > end {
80 bail!("invalid edit range: start {} is after end {}", start, end);
81 }
82
83 text.replace_range(start..end, &change.text);
84 Ok(())
85}
86
87pub fn byte_offset_at_position(text: &str, position: Position) -> Option<usize> {
88 let mut current_line: u32 = 0;
89 let mut current_character_utf16: u32 = 0;
90
91 for (index, ch) in text.char_indices() {
92 if current_line == position.line && current_character_utf16 == position.character {
93 return Some(index);
94 }
95
96 if ch == '\n' {
97 if current_line == position.line && current_character_utf16 == position.character {
98 return Some(index);
99 }
100 current_line = current_line.saturating_add(1);
101 current_character_utf16 = 0;
102 continue;
103 }
104
105 if current_line == position.line {
106 current_character_utf16 = current_character_utf16.saturating_add(ch.len_utf16() as u32);
107 if current_character_utf16 > position.character {
108 return None;
109 }
110 }
111 }
112
113 if current_line == position.line && current_character_utf16 == position.character {
114 Some(text.len())
115 } else {
116 None
117 }
118}
119
120pub fn utf16_length(text: &str) -> u32 {
121 text.chars().map(|ch| ch.len_utf16() as u32).sum()
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127 use lsp_types::{Position, Range, TextDocumentContentChangeEvent};
128
129 #[test]
130 fn changed_lines_between_marks_modified_line() {
131 let before = "one\ntwo\nthree\n";
132 let after = "one\nTWO\nthree\n";
133 let changed = changed_lines_between(before, after);
134 assert_eq!(changed, BTreeSet::from([2]));
135 }
136
137 #[test]
138 fn build_synthetic_diff_emits_hunks_for_changed_lines() {
139 let changed = BTreeSet::from([2_u32, 3_u32]);
140 let diff = build_synthetic_diff("src/lib.rs", "one\ntwo\nthree\n", &changed);
141 assert!(diff.contains("@@ -0,0 +2,1 @@"));
142 assert!(diff.contains("@@ -0,0 +3,1 @@"));
143 assert!(diff.contains("+two"));
144 assert!(diff.contains("+three"));
145 }
146
147 #[test]
148 fn apply_incremental_change_replaces_range() {
149 let mut text = "alpha\nbeta\n".to_string();
150 let change = TextDocumentContentChangeEvent {
151 range: Some(Range::new(Position::new(1, 0), Position::new(1, 4))),
152 range_length: None,
153 text: "gamma".to_string(),
154 };
155
156 apply_incremental_change(&mut text, &change).expect("apply");
157 assert_eq!(text, "alpha\ngamma\n");
158 }
159}