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}