voirs_cli/lsp/
formatting.rs1use super::{Position, Range};
6use serde_json::Value;
7
8pub 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
18pub fn format_range(text: &str, range: Range, language_id: &str) -> Option<Vec<TextEdit>> {
20 let range_text = extract_range_text(text, range)?;
22
23 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
36fn 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), ),
45 new_text: formatted,
46 }])
47}
48
49fn format_ssml_text(text: &str) -> Option<String> {
51 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 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 if current_tag.contains("</") {
77 indent = indent.saturating_sub(1);
79 result = result.trim_end().to_string();
81 } else if !current_tag.contains("/>") {
82 indent += 1;
84 }
85
86 result.push_str(¤t_tag);
87 current_tag.clear();
88 }
89 '\n' | '\r' if !in_tag => {
90 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
112fn 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
122fn format_json_text(text: &str) -> Option<String> {
124 let value: serde_json::Value = serde_json::from_str(text).ok()?;
126 serde_json::to_string_pretty(&value).ok()
127}
128
129fn format_toml(text: &str) -> Option<Vec<TextEdit>> {
131 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
156fn 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#[derive(Debug, Clone)]
201pub struct TextEdit {
202 pub range: Range,
204 pub new_text: String,
206}
207
208impl TextEdit {
209 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
227pub 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_complete_tag(text, position)
238 }
239 "json" if ch == '}' || ch == ']' => {
240 auto_indent_json(text, position)
242 }
243 _ => None,
244 }
245}
246
247fn 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 let before_pos = &line[..position.character as usize];
254
255 if let Some(tag_start) = before_pos.rfind('<') {
257 let tag_part = &before_pos[tag_start + 1..];
258
259 if tag_part.starts_with('/') || before_pos.ends_with('/') {
261 return None;
262 }
263
264 if let Some(tag_name) = tag_part.split_whitespace().next() {
266 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
281fn auto_indent_json(_text: &str, position: Position) -> Option<Vec<TextEdit>> {
283 Some(vec![TextEdit {
285 range: Range::new(Position::new(position.line, 0), position),
286 new_text: " ".to_string(), }])
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 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 assert!(!edits[0].new_text.contains("\n\n\n"));
370 }
371}