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}