Skip to main content

fresh/primitives/
indent_pattern.rs

1//! Pattern-based auto-indentation (WASM-compatible)
2//!
3//! This module provides language-agnostic indentation using pattern matching.
4//! It works for any language using C-style delimiters: `{ } [ ] ( )`
5//!
6//! # Design
7//!
8//! When tree-sitter is not available (e.g., WASM builds) or when syntax is
9//! incomplete during typing, this pattern-based approach provides reliable
10//! indentation by:
11//!
12//! 1. Scanning backwards through the buffer looking for delimiters
13//! 2. Tracking nesting depth to skip over already-matched pairs
14//! 3. Finding the unmatched opening delimiter to determine indent level
15//!
16//! # Example
17//!
18//! ```text
19//! if (true) {
20//!     hello
21//!     <cursor pressing Enter>
22//! ```
23//!
24//! The pattern matcher sees the unmatched `{` and increases indent by tab_size.
25
26use crate::model::buffer::Buffer;
27
28/// Pattern-based indent calculator (WASM-compatible)
29///
30/// Uses heuristic pattern matching instead of tree-sitter AST analysis.
31/// Works reliably with incomplete syntax which is common during typing.
32pub struct PatternIndentCalculator;
33
34impl PatternIndentCalculator {
35    /// Calculate indent for a new line at the given position
36    ///
37    /// Returns the number of spaces to indent.
38    pub fn calculate_indent(buffer: &Buffer, position: usize, tab_size: usize) -> usize {
39        // Pattern-based indent (for incomplete syntax)
40        if let Some(indent) = Self::calculate_indent_pattern(buffer, position, tab_size) {
41            return indent;
42        }
43
44        // Final fallback: copy current line's indent (maintain indentation)
45        Self::get_current_line_indent(buffer, position, tab_size)
46    }
47
48    /// Calculate the correct indent for a closing delimiter being typed
49    ///
50    /// When typing `}`, `]`, or `)`, this finds the matching opening delimiter
51    /// and returns its indentation level for proper alignment.
52    pub fn calculate_dedent_for_delimiter(
53        buffer: &Buffer,
54        position: usize,
55        _delimiter: char,
56        tab_size: usize,
57    ) -> Option<usize> {
58        Self::calculate_dedent_pattern(buffer, position, tab_size)
59    }
60
61    /// Pattern-based dedent calculation
62    ///
63    /// Scans backwards through the buffer tracking delimiter nesting to find
64    /// the matching unmatched opening delimiter.
65    ///
66    /// # Algorithm
67    /// 1. Start at cursor position, depth = 0
68    /// 2. Scan backwards line by line
69    /// 3. For each line's last non-whitespace character:
70    ///    - Closing delimiter (`}`, `]`, `)`): increment depth
71    ///    - Opening delimiter with depth > 0: decrement depth (matched pair)
72    ///    - Opening delimiter with depth = 0: **found!** Return its indent
73    fn calculate_dedent_pattern(
74        buffer: &Buffer,
75        position: usize,
76        tab_size: usize,
77    ) -> Option<usize> {
78        let mut depth = 0;
79        let mut search_pos = position;
80
81        while search_pos > 0 {
82            // Find start of line
83            let mut line_start = search_pos;
84            while line_start > 0 {
85                if Self::byte_at(buffer, line_start.saturating_sub(1)) == Some(b'\n') {
86                    break;
87                }
88                line_start = line_start.saturating_sub(1);
89            }
90
91            // Get line content
92            let line_bytes = buffer.slice_bytes(line_start..search_pos + 1);
93            let last_non_ws = line_bytes
94                .iter()
95                .rev()
96                .find(|&&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n');
97
98            if let Some(&last_char) = last_non_ws {
99                // Calculate this line's indentation
100                let line_indent =
101                    Self::count_leading_indent(buffer, line_start, search_pos, tab_size);
102
103                // Apply nesting depth tracking based on last character
104                match last_char {
105                    // Closing delimiter: increment depth to skip its matching opening
106                    b'}' | b']' | b')' => {
107                        depth += 1;
108                    }
109
110                    // Opening delimiter: check if it's matched or unmatched
111                    b'{' | b'[' | b'(' => {
112                        if depth > 0 {
113                            // Already matched by a closing delimiter we saw earlier
114                            depth -= 1;
115                        } else {
116                            // Unmatched! This is the opening delimiter we're closing
117                            return Some(line_indent);
118                        }
119                    }
120
121                    // Content line: continue searching
122                    _ => {}
123                }
124            }
125
126            // Move to previous line
127            if line_start == 0 {
128                break;
129            }
130            search_pos = line_start.saturating_sub(1);
131        }
132
133        // No matching opening delimiter found - dedent to column 0
134        Some(0)
135    }
136
137    /// Calculate indent using pattern matching
138    ///
139    /// Uses hybrid heuristic: finds previous non-empty line as reference,
140    /// then applies pattern-based deltas for opening delimiters.
141    fn calculate_indent_pattern(
142        buffer: &Buffer,
143        position: usize,
144        tab_size: usize,
145    ) -> Option<usize> {
146        if position == 0 {
147            return None;
148        }
149
150        // Find start of the line we're currently on
151        let mut line_start = position;
152        while line_start > 0 {
153            if Self::byte_at(buffer, line_start.saturating_sub(1)) == Some(b'\n') {
154                break;
155            }
156            line_start = line_start.saturating_sub(1);
157        }
158
159        // Get the content of the current line
160        let line_bytes = buffer.slice_bytes(line_start..position);
161
162        // Find the last non-whitespace character on current line
163        let last_non_whitespace = line_bytes
164            .iter()
165            .rev()
166            .find(|&&b| b != b' ' && b != b'\t' && b != b'\r');
167
168        // Check if current line is empty (only whitespace)
169        let current_line_is_empty = last_non_whitespace.is_none();
170
171        // Hybrid heuristic: find previous non-empty line for reference
172        let reference_indent = if !current_line_is_empty {
173            // Current line has content - use its indent as reference
174            Self::get_current_line_indent(buffer, position, tab_size)
175        } else {
176            // Current line is empty - find previous non-empty line
177            Self::find_reference_line_indent(buffer, line_start, tab_size)
178        };
179
180        // Check if line ends with an indent trigger
181        if let Some(&last_char) = last_non_whitespace {
182            match last_char {
183                b'{' | b'[' | b'(' | b':' => {
184                    return Some(reference_indent + tab_size);
185                }
186                _ => {}
187            }
188        }
189
190        // No pattern match - use reference indent
191        Some(reference_indent)
192    }
193
194    /// Find indent of previous non-empty line, checking for indent triggers
195    fn find_reference_line_indent(buffer: &Buffer, line_start: usize, tab_size: usize) -> usize {
196        let mut search_pos = if line_start > 0 {
197            line_start - 1
198        } else {
199            return 0;
200        };
201
202        while search_pos > 0 {
203            // Find start of line
204            let mut ref_line_start = search_pos;
205            while ref_line_start > 0 {
206                if Self::byte_at(buffer, ref_line_start.saturating_sub(1)) == Some(b'\n') {
207                    break;
208                }
209                ref_line_start = ref_line_start.saturating_sub(1);
210            }
211
212            // Check if this line has non-whitespace content
213            let ref_line_bytes = buffer.slice_bytes(ref_line_start..search_pos + 1);
214            let ref_last_non_ws = ref_line_bytes
215                .iter()
216                .rev()
217                .find(|&&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n');
218
219            if let Some(&last_char) = ref_last_non_ws {
220                // Found a non-empty reference line
221                let line_indent =
222                    Self::count_leading_indent(buffer, ref_line_start, search_pos, tab_size);
223
224                // Check if reference line ends with indent trigger
225                match last_char {
226                    b'{' | b'[' | b'(' | b':' => {
227                        return line_indent + tab_size;
228                    }
229                    _ => return line_indent,
230                }
231            }
232
233            // Move to previous line
234            if ref_line_start == 0 {
235                break;
236            }
237            search_pos = ref_line_start.saturating_sub(1);
238        }
239
240        0
241    }
242
243    /// Get a single byte at a position
244    pub fn byte_at(buffer: &Buffer, pos: usize) -> Option<u8> {
245        if pos >= buffer.len() {
246            return None;
247        }
248        buffer.slice_bytes(pos..pos + 1).first().copied()
249    }
250
251    /// Count leading whitespace indent
252    pub fn count_leading_indent(
253        buffer: &Buffer,
254        line_start: usize,
255        line_end: usize,
256        tab_size: usize,
257    ) -> usize {
258        let mut indent = 0;
259        let mut pos = line_start;
260        while pos < line_end {
261            match Self::byte_at(buffer, pos) {
262                Some(b' ') => indent += 1,
263                Some(b'\t') => indent += tab_size,
264                Some(b'\n') => break,
265                Some(_) => break, // Hit non-whitespace
266                None => break,
267            }
268            pos += 1;
269        }
270        indent
271    }
272
273    /// Get the indent of the current line
274    fn get_current_line_indent(buffer: &Buffer, position: usize, tab_size: usize) -> usize {
275        // Find start of current line
276        let mut line_start = position;
277        while line_start > 0 {
278            if Self::byte_at(buffer, line_start.saturating_sub(1)) == Some(b'\n') {
279                break;
280            }
281            line_start = line_start.saturating_sub(1);
282        }
283
284        Self::count_leading_indent(buffer, line_start, position, tab_size)
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::model::filesystem::NoopFileSystem;
292    use std::sync::Arc;
293
294    fn make_buffer(content: &str) -> Buffer {
295        let fs = Arc::new(NoopFileSystem);
296        let mut buf = Buffer::empty(fs);
297        buf.insert(0, content);
298        buf
299    }
300
301    #[test]
302    fn test_indent_after_brace() {
303        let buffer = make_buffer("fn main() {\n");
304        let indent = PatternIndentCalculator::calculate_indent(&buffer, buffer.len(), 4);
305        assert_eq!(indent, 4);
306    }
307
308    #[test]
309    fn test_dedent_for_closing_brace() {
310        let buffer = make_buffer("fn main() {\n    hello\n");
311        let dedent =
312            PatternIndentCalculator::calculate_dedent_for_delimiter(&buffer, buffer.len(), '}', 4);
313        assert_eq!(dedent, Some(0));
314    }
315
316    #[test]
317    fn test_maintain_indent() {
318        let buffer = make_buffer("    hello\n");
319        let indent = PatternIndentCalculator::calculate_indent(&buffer, buffer.len(), 4);
320        assert_eq!(indent, 4);
321    }
322
323    #[test]
324    fn test_nested_braces() {
325        let buffer = make_buffer("fn main() {\n    if true {\n        inner\n    }\n");
326        // Typing } should dedent to column 0 (matching the outer brace)
327        let dedent =
328            PatternIndentCalculator::calculate_dedent_for_delimiter(&buffer, buffer.len(), '}', 4);
329        assert_eq!(dedent, Some(0));
330    }
331}