Skip to main content

logicaffeine_lsp/
formatting.rs

1use tower_lsp::lsp_types::TextEdit;
2
3use crate::document::DocumentState;
4
5/// Range formatting: the whole-document structural format (a line's depth
6/// depends on the lines above it — depth is a document-level fact), filtered
7/// to the edits whose lines intersect the requested range. Sound because
8/// `format_source` is line-count preserving: every edit stays on its line.
9pub fn format_range(doc: &DocumentState, range: tower_lsp::lsp_types::Range) -> Vec<TextEdit> {
10    format_document(doc)
11        .into_iter()
12        .filter(|edit| {
13            edit.range.start.line <= range.end.line && edit.range.end.line >= range.start.line
14        })
15        .collect()
16}
17
18/// Handle document formatting request.
19///
20/// Formats the WHOLE document through the canonical LOGOS formatter
21/// ([`logicaffeine_language::source_format::format_source`] — identical to
22/// `largo fmt`, structural reindent and string/prose protection included)
23/// and emits per-line [`TextEdit`]s for the lines that changed.
24pub fn format_document(doc: &DocumentState) -> Vec<TextEdit> {
25    let mut edits = Vec::new();
26
27    let formatted = logicaffeine_language::source_format::format_source(&doc.source);
28    let mut formatted_lines = formatted.lines();
29
30    for (line_num, line) in doc.source.lines().enumerate() {
31        // format_source is line-count preserving by construction.
32        let new_line = formatted_lines.next().unwrap_or_default().to_string();
33
34        if new_line != line {
35            let line_start = doc.line_index.line_start_offset(line_num);
36            // The line's content ends at `line_start + line.len()` (always a
37            // char boundary). Terminated lines extend the range up to the
38            // `\n` so a `\r` (CRLF) is replaced away too; the final
39            // unterminated line ends exactly at EOF — never one byte short,
40            // which would duplicate the last character (or split a
41            // multibyte one and panic).
42            let content_end = line_start + line.len();
43            let next_start = doc.line_index.line_start_offset(line_num + 1);
44            let line_end = if next_start > content_end
45                && doc.source.as_bytes().get(next_start.saturating_sub(1)) == Some(&b'\n')
46            {
47                next_start - 1
48            } else {
49                content_end
50            };
51
52            let start = doc.line_index.position(line_start);
53            let end = doc.line_index.position(line_end);
54
55            edits.push(TextEdit {
56                range: tower_lsp::lsp_types::Range { start, end },
57                new_text: new_line,
58            });
59        }
60    }
61
62    edits
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::document::DocumentState;
69
70    fn make_doc(source: &str) -> DocumentState {
71        DocumentState::new(source.to_string(), 1)
72    }
73
74    #[test]
75    fn formatting_no_edits_for_spaces() {
76        let doc = make_doc("## Main\n    Let x be 5.\n");
77        let edits = format_document(&doc);
78        assert!(edits.is_empty(), "No edits expected for properly indented code");
79    }
80
81    #[test]
82    fn formatting_replaces_tabs() {
83        let doc = make_doc("## Main\n\tLet x be 5.\n");
84        let edits = format_document(&doc);
85        assert!(!edits.is_empty(), "Expected edits to replace tab with spaces");
86        assert!(
87            edits[0].new_text.contains("    "),
88            "Tab should be replaced with 4 spaces: {:?}",
89            edits[0].new_text
90        );
91    }
92
93    #[test]
94    fn formatting_empty_doc() {
95        let doc = make_doc("");
96        let edits = format_document(&doc);
97        assert!(edits.is_empty());
98    }
99
100    #[test]
101    fn formatting_reindents_by_lexed_depth_not_tab_width() {
102        // A double-tab as the FIRST indent is still one nesting level to the
103        // lexer — the canonical form is depth × 4 spaces, not width × 4.
104        let doc = make_doc("## Main\n\t\tLet x be 5.\n");
105        let edits = format_document(&doc);
106        assert!(!edits.is_empty(), "Expected edits for double-tabbed line");
107        assert_eq!(
108            edits[0].new_text, "    Let x be 5.",
109            "depth 1 canonicalizes to 4 spaces regardless of original width"
110        );
111    }
112
113    #[test]
114    fn formatting_large_file_with_tabs() {
115        // Regression test: formatting must not be O(n^2) with many tabbed lines
116        let mut source = "## Main\n".to_string();
117        for i in 0..100 {
118            source.push_str(&format!("\tLet x{} be {}.\n", i, i));
119        }
120        let doc = make_doc(&source);
121        let edits = format_document(&doc);
122        assert_eq!(edits.len(), 100, "Each tabbed line should produce an edit");
123    }
124
125    #[test]
126    fn edit_ranges_start_at_correct_line() {
127        let doc = make_doc("## Main\n\tLet x be 5.\n");
128        let edits = format_document(&doc);
129        assert!(!edits.is_empty());
130        assert_eq!(edits[0].range.start.line, 1, "Tab line edit should start on line 1");
131        assert_eq!(edits[0].range.start.character, 0, "Edit should start at character 0");
132    }
133
134    #[test]
135    fn no_panic_multi_line_tabs() {
136        let doc = make_doc("\tline1\n\tline2\n\tline3\n");
137        let edits = format_document(&doc);
138        assert_eq!(edits.len(), 3, "Each tabbed line should produce an edit");
139        for (i, edit) in edits.iter().enumerate() {
140            assert_eq!(edit.range.start.line, i as u32, "Edit {} should be on line {}", i, i);
141        }
142    }
143
144    #[test]
145    fn formatting_tab_replacement_produces_correct_ranges() {
146        let doc = make_doc("## Main\n\tLet x be 5.\n\tLet y be 10.\n");
147        let edits = format_document(&doc);
148        assert_eq!(edits.len(), 2, "Expected 2 edits for 2 tabbed lines");
149        // Second edit's range should start on line 2
150        assert_eq!(edits[1].range.start.line, 2, "Second edit should be on line 2");
151    }
152
153    #[test]
154    fn formatting_removes_trailing_whitespace() {
155        let doc = make_doc("## Main   \n    Let x be 5.   \n");
156        let edits = format_document(&doc);
157        assert!(!edits.is_empty(), "Expected edits for trailing whitespace");
158        for edit in &edits {
159            assert!(!edit.new_text.ends_with(' '),
160                "Edit should not end with spaces: {:?}", edit.new_text);
161        }
162    }
163
164    #[test]
165    fn formatting_handles_mixed_tabs_spaces() {
166        let doc = make_doc("## Main\n  \tLet x be 5.\n");
167        let edits = format_document(&doc);
168        assert!(!edits.is_empty(), "Expected edits for mixed tabs/spaces");
169        assert!(!edits[0].new_text.contains('\t'),
170            "Mixed tabs should be replaced: {:?}", edits[0].new_text);
171    }
172
173    #[test]
174    fn formatting_final_line_without_trailing_newline_covers_whole_line() {
175        // The edit range must span the ENTIRE final line — an off-by-one end
176        // would leave the last character outside the replacement and
177        // duplicate it when the client applies the edit.
178        let doc = make_doc("## Main\n\tX");
179        let edits = format_document(&doc);
180        assert_eq!(edits.len(), 1, "one edit for the tabbed final line");
181        assert_eq!(edits[0].new_text, "    X");
182        assert_eq!(edits[0].range.start.line, 1);
183        assert_eq!(edits[0].range.start.character, 0);
184        assert_eq!(
185            edits[0].range.end.character, 2,
186            "range must cover both characters of \"\\tX\""
187        );
188    }
189
190    #[test]
191    fn formatting_final_line_ending_in_multibyte_char_does_not_panic() {
192        let doc = make_doc("\tπ");
193        let edits = format_document(&doc);
194        assert_eq!(edits.len(), 1);
195        assert_eq!(edits[0].new_text, "    π");
196        assert_eq!(edits[0].range.end.character, 2, "tab + π in UTF-16 units");
197    }
198}