Skip to main content

voirs_cli/lsp/
formatting.rs

1//! LSP document formatting provider
2//!
3//! Provides automatic formatting for SSML and VoiRS configuration files.
4
5use super::{Position, Range};
6use serde_json::Value;
7
8/// Format a document
9pub fn format_document(text: &str, language_id: &str) -> Option<Vec<TextEdit>> {
10    match language_id {
11        "ssml" | "xml" => format_ssml(text),
12        "json" => format_json(text),
13        "toml" => format_toml(text),
14        _ => None,
15    }
16}
17
18/// Format a range in a document
19pub fn format_range(text: &str, range: Range, language_id: &str) -> Option<Vec<TextEdit>> {
20    // Extract range text
21    let range_text = extract_range_text(text, range)?;
22
23    // Format the extracted text
24    let formatted = match language_id {
25        "ssml" | "xml" => format_ssml_text(&range_text)?,
26        "json" => format_json_text(&range_text)?,
27        _ => return None,
28    };
29
30    Some(vec![TextEdit {
31        range,
32        new_text: formatted,
33    }])
34}
35
36/// Format SSML document
37fn format_ssml(text: &str) -> Option<Vec<TextEdit>> {
38    let formatted = format_ssml_text(text)?;
39
40    Some(vec![TextEdit {
41        range: Range::new(
42            Position::new(0, 0),
43            Position::new(u32::MAX, 0), // End of document
44        ),
45        new_text: formatted,
46    }])
47}
48
49/// Format SSML text
50fn format_ssml_text(text: &str) -> Option<String> {
51    // Simple SSML formatter
52    let mut result = String::new();
53    let mut indent = 0;
54    let mut in_tag = false;
55    let mut current_tag = String::new();
56
57    for ch in text.chars() {
58        match ch {
59            '<' => {
60                in_tag = true;
61                current_tag.clear();
62
63                // Check if closing tag
64                if result.ends_with('>') {
65                    result.push('\n');
66                    result.push_str(&"  ".repeat(indent));
67                }
68
69                current_tag.push(ch);
70            }
71            '>' => {
72                in_tag = false;
73                current_tag.push(ch);
74
75                // Check if self-closing or closing tag
76                if current_tag.contains("</") {
77                    // Closing tag
78                    indent = indent.saturating_sub(1);
79                    // Remove last newline+indent if present
80                    result = result.trim_end().to_string();
81                } else if !current_tag.contains("/>") {
82                    // Opening tag (not self-closing)
83                    indent += 1;
84                }
85
86                result.push_str(&current_tag);
87                current_tag.clear();
88            }
89            '\n' | '\r' if !in_tag => {
90                // Skip whitespace outside tags
91                continue;
92            }
93            c if in_tag => {
94                current_tag.push(c);
95            }
96            c => {
97                if !c.is_whitespace()
98                    || !result
99                        .chars()
100                        .last()
101                        .is_some_and(|last| last.is_whitespace())
102                {
103                    result.push(c);
104                }
105            }
106        }
107    }
108
109    Some(result.trim().to_string())
110}
111
112/// Format JSON document
113fn format_json(text: &str) -> Option<Vec<TextEdit>> {
114    let formatted = format_json_text(text)?;
115
116    Some(vec![TextEdit {
117        range: Range::new(Position::new(0, 0), Position::new(u32::MAX, 0)),
118        new_text: formatted,
119    }])
120}
121
122/// Format JSON text
123fn format_json_text(text: &str) -> Option<String> {
124    // Try to parse and pretty-print JSON
125    let value: serde_json::Value = serde_json::from_str(text).ok()?;
126    serde_json::to_string_pretty(&value).ok()
127}
128
129/// Format TOML document
130fn format_toml(text: &str) -> Option<Vec<TextEdit>> {
131    // Basic TOML formatting (normalize spacing)
132    let mut result = String::new();
133    let mut last_was_blank = false;
134
135    for line in text.lines() {
136        let trimmed = line.trim();
137
138        if trimmed.is_empty() {
139            if !last_was_blank {
140                result.push('\n');
141                last_was_blank = true;
142            }
143        } else {
144            result.push_str(trimmed);
145            result.push('\n');
146            last_was_blank = false;
147        }
148    }
149
150    Some(vec![TextEdit {
151        range: Range::new(Position::new(0, 0), Position::new(u32::MAX, 0)),
152        new_text: result.trim().to_string() + "\n",
153    }])
154}
155
156/// Extract text from range
157fn extract_range_text(text: &str, range: Range) -> Option<String> {
158    let lines: Vec<&str> = text.lines().collect();
159
160    if range.start.line == range.end.line {
161        let line = lines.get(range.start.line as usize)?;
162        let start = range.start.character as usize;
163        let end = range.end.character as usize;
164        if start < line.len() && end <= line.len() {
165            return Some(line[start..end].to_string());
166        }
167    } else {
168        let mut result = String::new();
169        for (i, line) in lines.iter().enumerate() {
170            let line_num = i as u32;
171            if line_num < range.start.line || line_num > range.end.line {
172                continue;
173            }
174
175            if line_num == range.start.line {
176                let start = range.start.character as usize;
177                if start < line.len() {
178                    result.push_str(&line[start..]);
179                    result.push('\n');
180                }
181            } else if line_num == range.end.line {
182                let end = range.end.character as usize;
183                if end <= line.len() {
184                    result.push_str(&line[..end]);
185                }
186            } else {
187                result.push_str(line);
188                result.push('\n');
189            }
190        }
191        if !result.is_empty() {
192            return Some(result);
193        }
194    }
195
196    None
197}
198
199/// Text edit for formatting
200#[derive(Debug, Clone)]
201pub struct TextEdit {
202    /// Range to replace
203    pub range: Range,
204    /// New text
205    pub new_text: String,
206}
207
208impl TextEdit {
209    /// Convert to LSP JSON format
210    pub fn to_json(&self) -> Value {
211        serde_json::json!({
212            "range": {
213                "start": {
214                    "line": self.range.start.line,
215                    "character": self.range.start.character
216                },
217                "end": {
218                    "line": self.range.end.line,
219                    "character": self.range.end.character
220                }
221            },
222            "newText": self.new_text
223        })
224    }
225}
226
227/// On-type formatting (triggered by specific characters)
228pub fn format_on_type(
229    text: &str,
230    position: Position,
231    ch: char,
232    language_id: &str,
233) -> Option<Vec<TextEdit>> {
234    match language_id {
235        "ssml" | "xml" if ch == '>' => {
236            // Auto-close tag or auto-indent
237            auto_complete_tag(text, position)
238        }
239        "json" if ch == '}' || ch == ']' => {
240            // Auto-indent closing brace
241            auto_indent_json(text, position)
242        }
243        _ => None,
244    }
245}
246
247/// Auto-complete SSML tag
248fn auto_complete_tag(text: &str, position: Position) -> Option<Vec<TextEdit>> {
249    let lines: Vec<&str> = text.lines().collect();
250    let line = lines.get(position.line as usize)?;
251
252    // Find the most recent opening tag before position
253    let before_pos = &line[..position.character as usize];
254
255    // Simple tag matching: find <tag and insert </tag>
256    if let Some(tag_start) = before_pos.rfind('<') {
257        let tag_part = &before_pos[tag_start + 1..];
258
259        // Skip if it's a closing tag or self-closing
260        if tag_part.starts_with('/') || before_pos.ends_with('/') {
261            return None;
262        }
263
264        // Extract tag name (first word)
265        if let Some(tag_name) = tag_part.split_whitespace().next() {
266            // Check if tag is not self-closing
267            if !["break", "meta", "desc"].contains(&tag_name) {
268                let closing_tag = format!("</{}>", tag_name);
269
270                return Some(vec![TextEdit {
271                    range: Range::new(position, position),
272                    new_text: closing_tag,
273                }]);
274            }
275        }
276    }
277
278    None
279}
280
281/// Auto-indent JSON closing brace
282fn auto_indent_json(_text: &str, position: Position) -> Option<Vec<TextEdit>> {
283    // Insert newline with proper indentation
284    Some(vec![TextEdit {
285        range: Range::new(Position::new(position.line, 0), position),
286        new_text: "  ".to_string(), // Basic 2-space indent
287    }])
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn test_format_json_text() {
296        let input = r#"{"key":"value","nested":{"a":1}}"#;
297        let formatted = format_json_text(input).unwrap();
298
299        assert!(formatted.contains("  \"key\": \"value\""));
300        assert!(formatted.contains('\n'));
301    }
302
303    #[test]
304    fn test_format_ssml_text() {
305        let input = "<speak><voice name=\"test\">Hello</voice></speak>";
306        let formatted = format_ssml_text(input).unwrap();
307
308        // Should have some structure (not exactly same as input)
309        assert!(formatted.contains("<speak>"));
310        assert!(formatted.contains("</speak>"));
311    }
312
313    #[test]
314    fn test_format_document_json() {
315        let input = r#"{"a":1,"b":2}"#;
316        let edits = format_document(input, "json").unwrap();
317
318        assert_eq!(edits.len(), 1);
319        assert!(edits[0].new_text.contains("  \"a\": 1"));
320    }
321
322    #[test]
323    fn test_format_on_type_tag() {
324        let text = "<speak";
325        let pos = Position::new(0, 6);
326
327        let edits = format_on_type(text, pos, '>', "ssml");
328        assert!(edits.is_some());
329    }
330
331    #[test]
332    fn test_text_edit_to_json() {
333        let edit = TextEdit {
334            range: Range::single_line(0, 0, 5),
335            new_text: "test".to_string(),
336        };
337
338        let json = edit.to_json();
339        assert_eq!(json["newText"].as_str().unwrap(), "test");
340        assert_eq!(json["range"]["start"]["line"].as_u64().unwrap(), 0);
341    }
342
343    #[test]
344    fn test_extract_range_text() {
345        let text = "Hello\nWorld\nTest";
346        let range = Range::new(Position::new(0, 0), Position::new(1, 5));
347
348        let extracted = extract_range_text(text, range).unwrap();
349        assert!(extracted.contains("Hello"));
350        assert!(extracted.contains("World"));
351    }
352
353    #[test]
354    fn test_auto_complete_tag_skip_self_closing() {
355        let text = "<break/";
356        let pos = Position::new(0, 7);
357
358        let edits = auto_complete_tag(text, pos);
359        assert!(edits.is_none());
360    }
361
362    #[test]
363    fn test_format_toml() {
364        let input = "[section]\nkey = \"value\"\n\n\n[other]\nkey2 = 123";
365        let edits = format_toml(input).unwrap();
366
367        assert_eq!(edits.len(), 1);
368        // Should normalize blank lines
369        assert!(!edits[0].new_text.contains("\n\n\n"));
370    }
371}