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