sql_cli/ui/utils/
text_operations.rs

1// Pure text manipulation functions with no TUI dependencies
2// These functions take text and cursor position, return results
3
4/// Result of a text operation that modifies the text
5#[derive(Debug, Clone)]
6pub struct TextOperationResult {
7    /// The new text after the operation
8    pub new_text: String,
9    /// The new cursor position after the operation
10    pub new_cursor_position: usize,
11    /// Text that was deleted/killed (for kill ring)
12    pub killed_text: Option<String>,
13    /// Description of what happened
14    pub description: String,
15}
16
17/// Result of a cursor movement operation
18#[derive(Debug, Clone)]
19pub struct CursorMovementResult {
20    /// The new cursor position
21    pub new_position: usize,
22    /// The word or token that was jumped over
23    pub jumped_text: Option<String>,
24}
25
26// ========== Pure Text Manipulation Functions ==========
27
28/// Kill text from cursor to end of line (Ctrl+K behavior)
29pub fn kill_line(text: &str, cursor_position: usize) -> TextOperationResult {
30    let text_len = text.len();
31
32    if cursor_position >= text_len {
33        // Cursor at end, nothing to kill
34        return TextOperationResult {
35            new_text: text.to_string(),
36            new_cursor_position: cursor_position,
37            killed_text: None,
38            description: "Nothing to kill".to_string(),
39        };
40    }
41
42    // Find the end of the current line
43    let line_end = text[cursor_position..]
44        .find('\n')
45        .map(|pos| cursor_position + pos)
46        .unwrap_or(text_len);
47
48    let killed = text[cursor_position..line_end].to_string();
49    let mut new_text = String::with_capacity(text_len);
50    new_text.push_str(&text[..cursor_position]);
51
52    // If we're killing up to a newline, keep the newline
53    if line_end < text_len && text.chars().nth(line_end) == Some('\n') {
54        new_text.push('\n');
55        new_text.push_str(&text[line_end + 1..]);
56    } else {
57        new_text.push_str(&text[line_end..]);
58    }
59
60    let killed_len = killed.len();
61    TextOperationResult {
62        new_text,
63        new_cursor_position: cursor_position,
64        killed_text: Some(killed),
65        description: format!("Killed {} characters", killed_len),
66    }
67}
68
69/// Kill text from beginning of line to cursor (Ctrl+U behavior)
70pub fn kill_line_backward(text: &str, cursor_position: usize) -> TextOperationResult {
71    if cursor_position == 0 {
72        // Cursor at start, nothing to kill
73        return TextOperationResult {
74            new_text: text.to_string(),
75            new_cursor_position: 0,
76            killed_text: None,
77            description: "Nothing to kill".to_string(),
78        };
79    }
80
81    // Find the start of the current line
82    let line_start = text[..cursor_position]
83        .rfind('\n')
84        .map(|pos| pos + 1)
85        .unwrap_or(0);
86
87    let killed = text[line_start..cursor_position].to_string();
88    let mut new_text = String::with_capacity(text.len());
89    new_text.push_str(&text[..line_start]);
90    new_text.push_str(&text[cursor_position..]);
91
92    let killed_len = killed.len();
93    TextOperationResult {
94        new_text,
95        new_cursor_position: line_start,
96        killed_text: Some(killed),
97        description: format!("Killed {} characters backward", killed_len),
98    }
99}
100
101/// Delete word backward from cursor (Ctrl+W behavior)
102pub fn delete_word_backward(text: &str, cursor_position: usize) -> TextOperationResult {
103    if cursor_position == 0 {
104        return TextOperationResult {
105            new_text: text.to_string(),
106            new_cursor_position: 0,
107            killed_text: None,
108            description: "At beginning of text".to_string(),
109        };
110    }
111
112    // Skip any trailing whitespace
113    let mut pos = cursor_position;
114    while pos > 0
115        && text
116            .chars()
117            .nth(pos - 1)
118            .map_or(false, |c| c.is_whitespace())
119    {
120        pos -= 1;
121    }
122
123    // Find the start of the word
124    let word_start = if pos == 0 {
125        0
126    } else {
127        let mut start = pos;
128        while start > 0
129            && !text
130                .chars()
131                .nth(start - 1)
132                .map_or(false, |c| c.is_whitespace())
133        {
134            start -= 1;
135        }
136        start
137    };
138
139    let killed = text[word_start..cursor_position].to_string();
140    let mut new_text = String::with_capacity(text.len());
141    new_text.push_str(&text[..word_start]);
142    new_text.push_str(&text[cursor_position..]);
143
144    let killed_trimmed = killed.trim().to_string();
145    TextOperationResult {
146        new_text,
147        new_cursor_position: word_start,
148        killed_text: Some(killed),
149        description: format!("Deleted word: '{}'", killed_trimmed),
150    }
151}
152
153/// Delete word forward from cursor (Alt+D behavior)
154pub fn delete_word_forward(text: &str, cursor_position: usize) -> TextOperationResult {
155    let text_len = text.len();
156    if cursor_position >= text_len {
157        return TextOperationResult {
158            new_text: text.to_string(),
159            new_cursor_position: cursor_position,
160            killed_text: None,
161            description: "At end of text".to_string(),
162        };
163    }
164
165    // Skip any leading whitespace
166    let mut pos = cursor_position;
167    while pos < text_len && text.chars().nth(pos).map_or(false, |c| c.is_whitespace()) {
168        pos += 1;
169    }
170
171    // Find the end of the word
172    let word_end = if pos >= text_len {
173        text_len
174    } else {
175        let mut end = pos;
176        while end < text_len && !text.chars().nth(end).map_or(false, |c| c.is_whitespace()) {
177            end += 1;
178        }
179        end
180    };
181
182    let killed = text[cursor_position..word_end].to_string();
183    let mut new_text = String::with_capacity(text.len());
184    new_text.push_str(&text[..cursor_position]);
185    new_text.push_str(&text[word_end..]);
186
187    let killed_trimmed = killed.trim().to_string();
188    TextOperationResult {
189        new_text,
190        new_cursor_position: cursor_position,
191        killed_text: Some(killed),
192        description: format!("Deleted word: '{}'", killed_trimmed),
193    }
194}
195
196// ========== Pure Cursor Movement Functions ==========
197
198/// Move cursor backward one word (Ctrl+Left or Alt+B)
199pub fn move_word_backward(text: &str, cursor_position: usize) -> CursorMovementResult {
200    if cursor_position == 0 {
201        return CursorMovementResult {
202            new_position: 0,
203            jumped_text: None,
204        };
205    }
206
207    // Skip any trailing whitespace
208    let mut pos = cursor_position;
209    while pos > 0
210        && text
211            .chars()
212            .nth(pos - 1)
213            .map_or(false, |c| c.is_whitespace())
214    {
215        pos -= 1;
216    }
217
218    // Find the start of the word
219    let word_start = if pos == 0 {
220        0
221    } else {
222        let mut start = pos;
223        while start > 0
224            && !text
225                .chars()
226                .nth(start - 1)
227                .map_or(false, |c| c.is_whitespace())
228        {
229            start -= 1;
230        }
231        start
232    };
233
234    let jumped = if word_start < cursor_position {
235        Some(text[word_start..cursor_position].to_string())
236    } else {
237        None
238    };
239
240    CursorMovementResult {
241        new_position: word_start,
242        jumped_text: jumped,
243    }
244}
245
246/// Move cursor forward one word (Ctrl+Right or Alt+F)
247pub fn move_word_forward(text: &str, cursor_position: usize) -> CursorMovementResult {
248    let text_len = text.len();
249    if cursor_position >= text_len {
250        return CursorMovementResult {
251            new_position: cursor_position,
252            jumped_text: None,
253        };
254    }
255
256    // Skip current word
257    let mut pos = cursor_position;
258    while pos < text_len && !text.chars().nth(pos).map_or(false, |c| c.is_whitespace()) {
259        pos += 1;
260    }
261
262    // Skip whitespace
263    while pos < text_len && text.chars().nth(pos).map_or(false, |c| c.is_whitespace()) {
264        pos += 1;
265    }
266
267    let jumped = if pos > cursor_position {
268        Some(text[cursor_position..pos].to_string())
269    } else {
270        None
271    };
272
273    CursorMovementResult {
274        new_position: pos,
275        jumped_text: jumped,
276    }
277}
278
279/// Jump to previous SQL token (more sophisticated than word)
280pub fn jump_to_prev_token(text: &str, cursor_position: usize) -> CursorMovementResult {
281    if cursor_position == 0 {
282        return CursorMovementResult {
283            new_position: 0,
284            jumped_text: None,
285        };
286    }
287
288    // SQL tokens include: keywords, identifiers, operators, literals
289    // For now, implement similar to word but can be enhanced for SQL
290    let mut pos = cursor_position;
291
292    // Skip any trailing whitespace or operators
293    while pos > 0 {
294        let ch = text.chars().nth(pos - 1);
295        if let Some(c) = ch {
296            if c.is_whitespace() || "(),;=<>!+-*/".contains(c) {
297                pos -= 1;
298            } else {
299                break;
300            }
301        } else {
302            break;
303        }
304    }
305
306    // Find the start of the token
307    let token_start = if pos == 0 {
308        0
309    } else {
310        let mut start = pos;
311        while start > 0 {
312            let ch = text.chars().nth(start - 1);
313            if let Some(c) = ch {
314                if !c.is_whitespace() && !"(),;=<>!+-*/".contains(c) {
315                    start -= 1;
316                } else {
317                    break;
318                }
319            } else {
320                break;
321            }
322        }
323        start
324    };
325
326    let jumped = if token_start < cursor_position {
327        Some(text[token_start..cursor_position].to_string())
328    } else {
329        None
330    };
331
332    CursorMovementResult {
333        new_position: token_start,
334        jumped_text: jumped,
335    }
336}
337
338/// Jump to next SQL token
339pub fn jump_to_next_token(text: &str, cursor_position: usize) -> CursorMovementResult {
340    let text_len = text.len();
341    if cursor_position >= text_len {
342        return CursorMovementResult {
343            new_position: cursor_position,
344            jumped_text: None,
345        };
346    }
347
348    let mut pos = cursor_position;
349
350    // Skip current token
351    while pos < text_len {
352        let ch = text.chars().nth(pos);
353        if let Some(c) = ch {
354            if !c.is_whitespace() && !"(),;=<>!+-*/".contains(c) {
355                pos += 1;
356            } else {
357                break;
358            }
359        } else {
360            break;
361        }
362    }
363
364    // Skip whitespace and operators to next token
365    while pos < text_len {
366        let ch = text.chars().nth(pos);
367        if let Some(c) = ch {
368            if c.is_whitespace() || "(),;=<>!+-*/".contains(c) {
369                pos += 1;
370            } else {
371                break;
372            }
373        } else {
374            break;
375        }
376    }
377
378    let jumped = if pos > cursor_position {
379        Some(text[cursor_position..pos].to_string())
380    } else {
381        None
382    };
383
384    CursorMovementResult {
385        new_position: pos,
386        jumped_text: jumped,
387    }
388}
389
390// ========== Helper Functions ==========
391
392/// Clear all text (simple helper)
393pub fn clear_text() -> TextOperationResult {
394    TextOperationResult {
395        new_text: String::new(),
396        new_cursor_position: 0,
397        killed_text: None,
398        description: "Cleared all text".to_string(),
399    }
400}
401
402/// Insert character at cursor position
403pub fn insert_char(text: &str, cursor_position: usize, ch: char) -> TextOperationResult {
404    let mut new_text = String::with_capacity(text.len() + 1);
405    new_text.push_str(&text[..cursor_position.min(text.len())]);
406    new_text.push(ch);
407    if cursor_position < text.len() {
408        new_text.push_str(&text[cursor_position..]);
409    }
410
411    TextOperationResult {
412        new_text,
413        new_cursor_position: cursor_position + 1,
414        killed_text: None,
415        description: format!("Inserted '{}'", ch),
416    }
417}
418
419/// Delete character at cursor position (Delete key)
420pub fn delete_char(text: &str, cursor_position: usize) -> TextOperationResult {
421    if cursor_position >= text.len() {
422        return TextOperationResult {
423            new_text: text.to_string(),
424            new_cursor_position: cursor_position,
425            killed_text: None,
426            description: "Nothing to delete".to_string(),
427        };
428    }
429
430    let deleted = text.chars().nth(cursor_position).unwrap();
431    let mut new_text = String::with_capacity(text.len() - 1);
432    new_text.push_str(&text[..cursor_position]);
433    new_text.push_str(&text[cursor_position + 1..]);
434
435    TextOperationResult {
436        new_text,
437        new_cursor_position: cursor_position,
438        killed_text: Some(deleted.to_string()),
439        description: format!("Deleted '{}'", deleted),
440    }
441}
442
443/// Delete character before cursor (Backspace)
444pub fn backspace(text: &str, cursor_position: usize) -> TextOperationResult {
445    if cursor_position == 0 {
446        return TextOperationResult {
447            new_text: text.to_string(),
448            new_cursor_position: 0,
449            killed_text: None,
450            description: "At beginning".to_string(),
451        };
452    }
453
454    let deleted = text.chars().nth(cursor_position - 1).unwrap();
455    let mut new_text = String::with_capacity(text.len() - 1);
456    new_text.push_str(&text[..cursor_position - 1]);
457    new_text.push_str(&text[cursor_position..]);
458
459    TextOperationResult {
460        new_text,
461        new_cursor_position: cursor_position - 1,
462        killed_text: Some(deleted.to_string()),
463        description: format!("Deleted '{}'", deleted),
464    }
465}
466
467#[cfg(test)]
468mod tests {
469    use super::*;
470
471    #[test]
472    fn test_kill_line() {
473        let text = "SELECT * FROM table WHERE id = 1";
474        let result = kill_line(text, 7);
475        assert_eq!(result.new_text, "SELECT ");
476        assert_eq!(
477            result.killed_text,
478            Some("* FROM table WHERE id = 1".to_string())
479        );
480        assert_eq!(result.new_cursor_position, 7);
481    }
482
483    #[test]
484    fn test_kill_line_backward() {
485        let text = "SELECT * FROM table";
486        let result = kill_line_backward(text, 7);
487        assert_eq!(result.new_text, "* FROM table");
488        assert_eq!(result.killed_text, Some("SELECT ".to_string()));
489        assert_eq!(result.new_cursor_position, 0);
490    }
491
492    #[test]
493    fn test_delete_word_backward() {
494        let text = "SELECT * FROM table";
495        let result = delete_word_backward(text, 13); // After "FROM"
496        assert_eq!(result.new_text, "SELECT *  table");
497        assert_eq!(result.killed_text, Some("FROM".to_string()));
498        assert_eq!(result.new_cursor_position, 9);
499    }
500
501    #[test]
502    fn test_move_word_forward() {
503        let text = "SELECT * FROM table";
504        let result = move_word_forward(text, 0);
505        assert_eq!(result.new_position, 7); // After "SELECT "
506
507        let result2 = move_word_forward(text, 7);
508        assert_eq!(result2.new_position, 9); // After "* "
509    }
510
511    #[test]
512    fn test_move_word_backward() {
513        let text = "SELECT * FROM table";
514        let result = move_word_backward(text, 13); // From end of "FROM"
515        assert_eq!(result.new_position, 9); // Start of "FROM"
516
517        let result2 = move_word_backward(text, 9);
518        assert_eq!(result2.new_position, 7); // Start of "*"
519    }
520
521    #[test]
522    fn test_jump_to_next_token() {
523        let text = "SELECT id, name FROM users WHERE id = 1";
524        let result = jump_to_next_token(text, 0);
525        assert_eq!(result.new_position, 7); // After "SELECT "
526
527        let result2 = jump_to_next_token(text, 7);
528        assert_eq!(result2.new_position, 11); // After "id, " (skips comma and space)
529    }
530
531    #[test]
532    fn test_insert_and_delete() {
533        let text = "SELECT";
534        let result = insert_char(text, 6, ' ');
535        assert_eq!(result.new_text, "SELECT ");
536        assert_eq!(result.new_cursor_position, 7);
537
538        let result2 = delete_char(&result.new_text, 6);
539        assert_eq!(result2.new_text, "SELECT");
540
541        let result3 = backspace(&result.new_text, 7);
542        assert_eq!(result3.new_text, "SELECT");
543        assert_eq!(result3.new_cursor_position, 6);
544    }
545}
546
547// ========== SQL-Specific Text Functions ==========
548
549/// Extract partial word at cursor for SQL completion
550/// Handles quoted identifiers and SQL-specific parsing
551pub fn extract_partial_word_at_cursor(query: &str, cursor_pos: usize) -> Option<String> {
552    if cursor_pos == 0 || cursor_pos > query.len() {
553        return None;
554    }
555
556    let chars: Vec<char> = query.chars().collect();
557    let mut start = cursor_pos;
558    let end = cursor_pos;
559
560    // Check if we might be in a quoted identifier
561    let mut in_quote = false;
562
563    // Find start of word (go backward)
564    while start > 0 {
565        let prev_char = chars[start - 1];
566        if prev_char == '"' {
567            // Found a quote, include it and stop
568            start -= 1;
569            in_quote = true;
570            break;
571        } else if prev_char.is_alphanumeric() || prev_char == '_' || (prev_char == ' ' && in_quote)
572        {
573            start -= 1;
574        } else {
575            break;
576        }
577    }
578
579    // If we found a quote but are in a quoted identifier,
580    // we need to continue backwards to include the identifier content
581    if in_quote && start > 0 {
582        // We've already moved past the quote, now get the content before it
583        // Actually, we want to include everything from the quote forward
584        // The logic above is correct - we stop at the quote
585    }
586
587    // Convert back to byte positions
588    let start_byte = chars[..start].iter().map(|c| c.len_utf8()).sum();
589    let end_byte = chars[..end].iter().map(|c| c.len_utf8()).sum();
590
591    if start_byte < end_byte {
592        Some(query[start_byte..end_byte].to_string())
593    } else {
594        None
595    }
596}
597
598/// Result of applying a completion to text
599#[derive(Debug, Clone)]
600pub struct CompletionResult {
601    /// The new text with completion applied
602    pub new_text: String,
603    /// The new cursor position after completion
604    pub new_cursor_position: usize,
605    /// Description of what was completed
606    pub description: String,
607}
608
609/// Apply a completion suggestion to text at cursor position
610/// Handles quoted identifiers and smart cursor positioning
611pub fn apply_completion_to_text(
612    query: &str,
613    cursor_pos: usize,
614    partial_word: &str,
615    suggestion: &str,
616) -> CompletionResult {
617    let before_partial = &query[..cursor_pos - partial_word.len()];
618    let after_cursor = &query[cursor_pos..];
619
620    // Handle quoted identifiers - avoid double quotes
621    let suggestion_to_use = if partial_word.starts_with('"') && suggestion.starts_with('"') {
622        // The partial already includes the opening quote, so use suggestion without its quote
623        if suggestion.len() > 1 {
624            suggestion[1..].to_string()
625        } else {
626            suggestion.to_string()
627        }
628    } else {
629        suggestion.to_string()
630    };
631
632    let new_query = format!("{}{}{}", before_partial, suggestion_to_use, after_cursor);
633
634    // Smart cursor positioning based on function signature
635    let new_cursor_pos = if suggestion_to_use.ends_with("('')") {
636        // Function with parameters - position cursor between the quotes
637        // e.g., Contains('|') where | is cursor
638        before_partial.len() + suggestion_to_use.len() - 2
639    } else if suggestion_to_use.ends_with("()") {
640        // Parameterless function - position cursor after closing parenthesis
641        // e.g., Length()| where | is cursor
642        before_partial.len() + suggestion_to_use.len()
643    } else {
644        // Regular completion (not a function) - position at end
645        before_partial.len() + suggestion_to_use.len()
646    };
647
648    // Better description based on completion type
649    let description = if suggestion_to_use.ends_with("('')") {
650        format!(
651            "Completed '{}' with cursor positioned for parameter input",
652            suggestion
653        )
654    } else if suggestion_to_use.ends_with("()") {
655        format!("Completed parameterless function '{}'", suggestion)
656    } else {
657        format!("Completed '{}'", suggestion)
658    };
659
660    CompletionResult {
661        new_text: new_query,
662        new_cursor_position: new_cursor_pos,
663        description,
664    }
665}