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