Skip to main content

diffguard_lsp/
text.rs

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}