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(b'{' | b'[' | b'(' | b':') = last_non_whitespace {
182            return Some(reference_indent + tab_size);
183        }
184
185        // No pattern match - use reference indent
186        Some(reference_indent)
187    }
188
189    /// Find indent of previous non-empty line, checking for indent triggers
190    fn find_reference_line_indent(buffer: &Buffer, line_start: usize, tab_size: usize) -> usize {
191        let mut search_pos = if line_start > 0 {
192            line_start - 1
193        } else {
194            return 0;
195        };
196
197        while search_pos > 0 {
198            // Find start of line
199            let mut ref_line_start = search_pos;
200            while ref_line_start > 0 {
201                if Self::byte_at(buffer, ref_line_start.saturating_sub(1)) == Some(b'\n') {
202                    break;
203                }
204                ref_line_start = ref_line_start.saturating_sub(1);
205            }
206
207            // Check if this line has non-whitespace content
208            let ref_line_bytes = buffer.slice_bytes(ref_line_start..search_pos + 1);
209            let ref_last_non_ws = ref_line_bytes
210                .iter()
211                .rev()
212                .find(|&&b| b != b' ' && b != b'\t' && b != b'\r' && b != b'\n');
213
214            if let Some(&last_char) = ref_last_non_ws {
215                // Found a non-empty reference line
216                let line_indent =
217                    Self::count_leading_indent(buffer, ref_line_start, search_pos, tab_size);
218
219                // Check if reference line ends with indent trigger
220                match last_char {
221                    b'{' | b'[' | b'(' | b':' => {
222                        return line_indent + tab_size;
223                    }
224                    _ => return line_indent,
225                }
226            }
227
228            // Move to previous line
229            if ref_line_start == 0 {
230                break;
231            }
232            search_pos = ref_line_start.saturating_sub(1);
233        }
234
235        0
236    }
237
238    /// Get a single byte at a position
239    pub fn byte_at(buffer: &Buffer, pos: usize) -> Option<u8> {
240        if pos >= buffer.len() {
241            return None;
242        }
243        buffer.slice_bytes(pos..pos + 1).first().copied()
244    }
245
246    /// Count leading whitespace indent
247    pub fn count_leading_indent(
248        buffer: &Buffer,
249        line_start: usize,
250        line_end: usize,
251        tab_size: usize,
252    ) -> usize {
253        let mut indent = 0;
254        let mut pos = line_start;
255        while pos < line_end {
256            match Self::byte_at(buffer, pos) {
257                Some(b' ') => indent += 1,
258                Some(b'\t') => indent += tab_size,
259                Some(b'\n') => break,
260                Some(_) => break, // Hit non-whitespace
261                None => break,
262            }
263            pos += 1;
264        }
265        indent
266    }
267
268    /// Get the indent of the current line
269    fn get_current_line_indent(buffer: &Buffer, position: usize, tab_size: usize) -> usize {
270        // Find start of current line
271        let mut line_start = position;
272        while line_start > 0 {
273            if Self::byte_at(buffer, line_start.saturating_sub(1)) == Some(b'\n') {
274                break;
275            }
276            line_start = line_start.saturating_sub(1);
277        }
278
279        Self::count_leading_indent(buffer, line_start, position, tab_size)
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use crate::model::filesystem::NoopFileSystem;
287    use std::sync::Arc;
288
289    fn make_buffer(content: &str) -> Buffer {
290        let fs = Arc::new(NoopFileSystem);
291        let mut buf = Buffer::empty(fs);
292        buf.insert(0, content);
293        buf
294    }
295
296    #[test]
297    fn test_indent_after_brace() {
298        let buffer = make_buffer("fn main() {\n");
299        let indent = PatternIndentCalculator::calculate_indent(&buffer, buffer.len(), 4);
300        assert_eq!(indent, 4);
301    }
302
303    #[test]
304    fn test_dedent_for_closing_brace() {
305        let buffer = make_buffer("fn main() {\n    hello\n");
306        let dedent =
307            PatternIndentCalculator::calculate_dedent_for_delimiter(&buffer, buffer.len(), '}', 4);
308        assert_eq!(dedent, Some(0));
309    }
310
311    #[test]
312    fn test_maintain_indent() {
313        let buffer = make_buffer("    hello\n");
314        let indent = PatternIndentCalculator::calculate_indent(&buffer, buffer.len(), 4);
315        assert_eq!(indent, 4);
316    }
317
318    #[test]
319    fn test_nested_braces() {
320        let buffer = make_buffer("fn main() {\n    if true {\n        inner\n    }\n");
321        // Typing } should dedent to column 0 (matching the outer brace)
322        let dedent =
323            PatternIndentCalculator::calculate_dedent_for_delimiter(&buffer, buffer.len(), '}', 4);
324        assert_eq!(dedent, Some(0));
325    }
326}