Skip to main content

sql_cli/
text_navigation.rs

1use crate::recursive_parser::{Lexer, Token};
2
3/// Manages text navigation and token-based movement
4/// Extracted from the monolithic `enhanced_tui.rs`
5pub struct TextNavigator;
6
7impl TextNavigator {
8    /// Get the cursor's position in terms of tokens (`current_token`, `total_tokens`)
9    #[must_use]
10    pub fn get_cursor_token_position(query: &str, cursor_pos: usize) -> (usize, usize) {
11        if query.is_empty() {
12            return (0, 0);
13        }
14
15        // Use lexer to tokenize the query
16        let mut lexer = Lexer::new(query);
17        let tokens = lexer.tokenize_all_with_positions();
18
19        if tokens.is_empty() {
20            return (0, 0);
21        }
22
23        // Special case: cursor at position 0 is always before the first token
24        if cursor_pos == 0 {
25            return (0, tokens.len());
26        }
27
28        // Find which token the cursor is in
29        let mut current_token = 0;
30        for (i, (start, end, _)) in tokens.iter().enumerate() {
31            if cursor_pos >= *start && cursor_pos <= *end {
32                current_token = i + 1;
33                break;
34            } else if cursor_pos < *start {
35                // Cursor is between tokens
36                current_token = i;
37                break;
38            }
39        }
40
41        // If cursor is after all tokens
42        if current_token == 0 && cursor_pos > 0 {
43            current_token = tokens.len();
44        }
45
46        (current_token, tokens.len())
47    }
48
49    /// Get the token at the cursor position
50    #[must_use]
51    pub fn get_token_at_cursor(query: &str, cursor_pos: usize) -> Option<String> {
52        if query.is_empty() {
53            return None;
54        }
55
56        // Use lexer to tokenize the query
57        let mut lexer = Lexer::new(query);
58        let tokens = lexer.tokenize_all_with_positions();
59
60        // Find the token at cursor position
61        for (start, end, token) in &tokens {
62            if cursor_pos >= *start && cursor_pos <= *end {
63                // Format token nicely
64                let token_str = Self::format_token(token);
65                return Some(token_str.to_string());
66            }
67        }
68
69        None
70    }
71
72    /// Calculate the target position for jumping to the previous token
73    #[must_use]
74    pub fn calculate_prev_token_position(query: &str, cursor_pos: usize) -> Option<usize> {
75        if cursor_pos == 0 {
76            return None;
77        }
78
79        let mut lexer = Lexer::new(query);
80        let tokens = lexer.tokenize_all_with_positions();
81
82        // Find current token position
83        let mut in_token = false;
84        let mut current_token_start = 0;
85        for (start, end, _) in &tokens {
86            if cursor_pos > *start && cursor_pos <= *end {
87                in_token = true;
88                current_token_start = *start;
89                break;
90            }
91        }
92
93        // Find the previous token start
94        let target_pos = if in_token && cursor_pos > current_token_start {
95            // If we're in the middle of a token, go to its start
96            current_token_start
97        } else {
98            // Otherwise, find the previous token
99            let mut prev_start = 0;
100            for (start, _, _) in tokens.iter().rev() {
101                if *start < cursor_pos {
102                    prev_start = *start;
103                    break;
104                }
105            }
106            prev_start
107        };
108
109        if target_pos < cursor_pos {
110            Some(target_pos)
111        } else {
112            None
113        }
114    }
115
116    /// Calculate the target position for jumping to the next token
117    #[must_use]
118    pub fn calculate_next_token_position(query: &str, cursor_pos: usize) -> Option<usize> {
119        let query_len = query.len();
120        if cursor_pos >= query_len {
121            return None;
122        }
123
124        let mut lexer = Lexer::new(query);
125        let tokens = lexer.tokenize_all_with_positions();
126
127        // Find current token position
128        let mut in_token = false;
129        let mut current_token_end = query_len;
130        for (start, end, _) in &tokens {
131            if cursor_pos >= *start && cursor_pos < *end {
132                in_token = true;
133                current_token_end = *end;
134                break;
135            }
136        }
137
138        // Find the next token start
139        let target_pos = if in_token && cursor_pos < current_token_end {
140            // If we're in a token, go to the start of the next token
141            let mut next_start = query_len;
142            for (start, _, _) in &tokens {
143                if *start > current_token_end {
144                    next_start = *start;
145                    break;
146                }
147            }
148            next_start
149        } else {
150            // Otherwise, find the next token from current position
151            let mut next_start = query_len;
152            for (start, _, _) in &tokens {
153                if *start > cursor_pos {
154                    next_start = *start;
155                    break;
156                }
157            }
158            next_start
159        };
160
161        if target_pos > cursor_pos && target_pos <= query_len {
162            Some(target_pos)
163        } else {
164            None
165        }
166    }
167
168    /// Format a token for display
169    fn format_token(token: &Token) -> &str {
170        match token {
171            Token::Select => "SELECT",
172            Token::From => "FROM",
173            Token::Where => "WHERE",
174            Token::With => "WITH",
175            Token::GroupBy => "GROUP BY",
176            Token::OrderBy => "ORDER BY",
177            Token::Having => "HAVING",
178            Token::As => "AS",
179            Token::Asc => "ASC",
180            Token::Desc => "DESC",
181            Token::And => "AND",
182            Token::Or => "OR",
183            Token::In => "IN",
184            Token::DateTime => "DateTime",
185            Token::Case => "CASE",
186            Token::When => "WHEN",
187            Token::Then => "THEN",
188            Token::Else => "ELSE",
189            Token::End => "END",
190            Token::Distinct => "DISTINCT",
191            Token::Exclude => "EXCLUDE",
192            Token::Pivot => "PIVOT",
193            Token::Unpivot => "UNPIVOT",
194            Token::For => "FOR",
195            Token::Over => "OVER",
196            Token::Partition => "PARTITION",
197            Token::By => "BY",
198            // Window frame keywords
199            Token::Rows => "ROWS",
200            Token::Range => "RANGE",
201            Token::Unbounded => "UNBOUNDED",
202            Token::Preceding => "PRECEDING",
203            Token::Following => "FOLLOWING",
204            Token::Current => "CURRENT",
205            Token::Row => "ROW",
206            // Set operation keywords
207            Token::Union => "UNION",
208            Token::Intersect => "INTERSECT",
209            Token::Except => "EXCEPT",
210            // Special CTE keywords
211            Token::Web => "WEB",
212            Token::File => "FILE",
213            // Row expansion functions
214            Token::Unnest => "UNNEST",
215            Token::Identifier(s) => s,
216            Token::QuotedIdentifier(s) => s,
217            Token::StringLiteral(s) => s,
218            Token::JsonBlock(s) => s,
219            Token::NumberLiteral(s) => s,
220            Token::Star => "*",
221            Token::Comma => ",",
222            Token::Semicolon => ";",
223            Token::Colon => ":",
224            Token::Dot => ".",
225            Token::LeftParen => "(",
226            Token::RightParen => ")",
227            Token::Equal => "=",
228            Token::NotEqual => "!=",
229            Token::LessThan => "<",
230            Token::LessThanOrEqual => "<=",
231            Token::GreaterThan => ">",
232            Token::GreaterThanOrEqual => ">=",
233            Token::Like => "LIKE",
234            Token::ILike => "ILIKE",
235            Token::Not => "NOT",
236            Token::Is => "IS",
237            Token::Null => "NULL",
238            Token::Between => "BETWEEN",
239            Token::Limit => "LIMIT",
240            Token::Offset => "OFFSET",
241            Token::Into => "INTO",
242            Token::Plus => "+",
243            Token::Minus => "-",
244            Token::Divide => "/",
245            Token::Modulo => "%",
246            Token::Concat => "||",
247            Token::Join => "JOIN",
248            Token::Inner => "INNER",
249            Token::Left => "LEFT",
250            Token::Right => "RIGHT",
251            Token::Full => "FULL",
252            Token::Cross => "CROSS",
253            Token::Outer => "OUTER",
254            Token::On => "ON",
255            Token::LineComment(text) => text,
256            Token::BlockComment(text) => text,
257            Token::Eof => "EOF",
258            Token::Qualify => "QUALIFY",
259        }
260    }
261}
262
263/// Text editing utilities
264pub struct TextEditor;
265
266impl TextEditor {
267    /// Kill text from beginning of line to cursor position
268    /// Returns (`killed_text`, `remaining_text`)
269    #[must_use]
270    pub fn kill_line_backward(text: &str, cursor_pos: usize) -> Option<(String, String)> {
271        if cursor_pos == 0 {
272            return None;
273        }
274
275        let killed_text = text.chars().take(cursor_pos).collect::<String>();
276        let remaining_text = text.chars().skip(cursor_pos).collect::<String>();
277
278        Some((killed_text, remaining_text))
279    }
280
281    /// Kill text from cursor position to end of line
282    /// Returns (`killed_text`, `remaining_text`)
283    #[must_use]
284    pub fn kill_line_forward(text: &str, cursor_pos: usize) -> Option<(String, String)> {
285        if cursor_pos >= text.len() {
286            return None;
287        }
288
289        let remaining_text = text.chars().take(cursor_pos).collect::<String>();
290        let killed_text = text.chars().skip(cursor_pos).collect::<String>();
291
292        Some((killed_text, remaining_text))
293    }
294
295    /// Delete word backward from cursor position
296    /// Returns (`deleted_text`, `remaining_text`, `new_cursor_pos`)
297    #[must_use]
298    pub fn delete_word_backward(text: &str, cursor_pos: usize) -> Option<(String, String, usize)> {
299        if cursor_pos == 0 {
300            return None;
301        }
302
303        let before_cursor = &text[..cursor_pos];
304        let after_cursor = &text[cursor_pos..];
305
306        // Find word boundary, including leading whitespace before the word
307        let mut word_start = before_cursor.len();
308        let mut chars = before_cursor.chars().rev().peekable();
309
310        // Step 1: Skip trailing whitespace (if any)
311        while let Some(&ch) = chars.peek() {
312            if ch.is_whitespace() {
313                word_start -= ch.len_utf8();
314                chars.next();
315            } else {
316                break;
317            }
318        }
319
320        // Step 2: Skip the word itself
321        while let Some(&ch) = chars.peek() {
322            if !ch.is_alphanumeric() && ch != '_' {
323                break;
324            }
325            word_start -= ch.len_utf8();
326            chars.next();
327        }
328
329        // Step 3: Include any whitespace before the word (so deleting at a word boundary includes the space)
330        while let Some(&ch) = chars.peek() {
331            if ch.is_whitespace() {
332                word_start -= ch.len_utf8();
333                chars.next();
334            } else {
335                break;
336            }
337        }
338
339        let deleted_text = text[word_start..cursor_pos].to_string();
340        let remaining_text = format!("{}{}", &text[..word_start], after_cursor);
341
342        Some((deleted_text, remaining_text, word_start))
343    }
344
345    /// Delete word forward from cursor position
346    /// Returns (`deleted_text`, `remaining_text`)
347    #[must_use]
348    pub fn delete_word_forward(text: &str, cursor_pos: usize) -> Option<(String, String)> {
349        if cursor_pos >= text.len() {
350            return None;
351        }
352
353        let before_cursor = &text[..cursor_pos];
354        let after_cursor = &text[cursor_pos..];
355
356        // Find word boundary
357        let mut chars = after_cursor.chars();
358        let mut word_end = 0;
359
360        // Skip any non-alphanumeric chars at the beginning
361        while let Some(ch) = chars.next() {
362            word_end += ch.len_utf8();
363            if ch.is_alphanumeric() || ch == '_' {
364                // Found start of word, now skip the rest of it
365                for ch in chars.by_ref() {
366                    if !ch.is_alphanumeric() && ch != '_' {
367                        break;
368                    }
369                    word_end += ch.len_utf8();
370                }
371                break;
372            }
373        }
374
375        let deleted_text = text[cursor_pos..cursor_pos + word_end].to_string();
376        let remaining_text = format!("{}{}", before_cursor, &after_cursor[word_end..]);
377
378        Some((deleted_text, remaining_text))
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use super::*;
385
386    #[test]
387    fn test_cursor_token_position() {
388        let query = "SELECT * FROM users WHERE id = 1";
389
390        // Cursor at beginning
391        assert_eq!(TextNavigator::get_cursor_token_position(query, 0), (0, 8));
392
393        // Cursor in SELECT
394        assert_eq!(TextNavigator::get_cursor_token_position(query, 3), (1, 8));
395
396        // Cursor after SELECT
397        assert_eq!(TextNavigator::get_cursor_token_position(query, 7), (2, 8));
398    }
399
400    #[test]
401    fn test_kill_line_backward() {
402        let text = "SELECT * FROM users";
403
404        // Kill from middle
405        let result = TextEditor::kill_line_backward(text, 8);
406        assert_eq!(
407            result,
408            Some(("SELECT *".to_string(), " FROM users".to_string()))
409        );
410
411        // Kill from beginning (no-op)
412        let result = TextEditor::kill_line_backward(text, 0);
413        assert_eq!(result, None);
414    }
415
416    #[test]
417    fn test_delete_word_backward() {
418        let text = "SELECT * FROM users";
419
420        // Delete "FROM"
421        let result = TextEditor::delete_word_backward(text, 13);
422        assert_eq!(
423            result,
424            Some((" FROM".to_string(), "SELECT * users".to_string(), 8))
425        );
426    }
427}