sql_cli/ui/utils/
text_utils.rs

1/// Text processing utilities extracted from `enhanced_tui`
2/// Extract a partial word at the cursor position in a query string
3/// Used for completion and search functionality
4#[must_use]
5pub fn extract_partial_word_at_cursor(query: &str, cursor_pos: usize) -> Option<String> {
6    if cursor_pos == 0 || cursor_pos > query.len() {
7        return None;
8    }
9
10    let chars: Vec<char> = query.chars().collect();
11    let mut start = cursor_pos;
12    let end = cursor_pos;
13
14    // Check if we might be in a quoted identifier by scanning backwards
15    let mut in_quote = false;
16
17    // First, check if we're inside quotes by looking for an opening quote before cursor
18    for i in (0..cursor_pos).rev() {
19        if i < chars.len() && chars[i] == '"' {
20            // Found a potential opening quote
21            // Check if there's a closing quote after cursor or not
22            let mut found_closing = false;
23            for j in cursor_pos..chars.len() {
24                if chars[j] == '"' {
25                    found_closing = true;
26                    break;
27                }
28            }
29            // If no closing quote found, or cursor is before the closing quote, we're in a quoted identifier
30            if !found_closing || cursor_pos <= chars.len() {
31                in_quote = true;
32                start = i;
33                break;
34            }
35        }
36    }
37
38    // If we found an opening quote, include everything up to cursor
39    if in_quote {
40        // Convert back to byte positions
41        let start_byte = chars[..start].iter().map(|c| c.len_utf8()).sum();
42        let end_byte = chars[..end].iter().map(|c| c.len_utf8()).sum();
43
44        if start_byte < end_byte {
45            return Some(query[start_byte..end_byte].to_string());
46        }
47    }
48
49    // Otherwise, find start of word normally (go backward)
50    while start > 0 {
51        let prev_char = chars[start - 1];
52        if prev_char.is_alphanumeric() || prev_char == '_' {
53            start -= 1;
54        } else {
55            break;
56        }
57    }
58
59    // Convert back to byte positions
60    let start_byte = chars[..start].iter().map(|c| c.len_utf8()).sum();
61    let end_byte = chars[..end].iter().map(|c| c.len_utf8()).sum();
62
63    if start_byte < end_byte {
64        Some(query[start_byte..end_byte].to_string())
65    } else {
66        None
67    }
68}
69
70/// Get the token at cursor position in SQL text
71#[must_use]
72pub fn get_token_at_cursor(sql_text: &str, cursor_pos: usize) -> Option<String> {
73    if sql_text.is_empty() || cursor_pos > sql_text.len() {
74        return None;
75    }
76
77    let chars: Vec<char> = sql_text.chars().collect();
78    if cursor_pos > chars.len() {
79        return None;
80    }
81
82    // Find word boundaries
83    let mut start = cursor_pos;
84    let mut end = cursor_pos;
85
86    // Move start backward to beginning of word
87    while start > 0 {
88        let idx = start - 1;
89        if idx < chars.len() && (chars[idx].is_alphanumeric() || chars[idx] == '_') {
90            start -= 1;
91        } else {
92            break;
93        }
94    }
95
96    // Move end forward to end of word
97    while end < chars.len() {
98        if chars[end].is_alphanumeric() || chars[end] == '_' {
99            end += 1;
100        } else {
101            break;
102        }
103    }
104
105    if start < end {
106        let token: String = chars[start..end].iter().collect();
107        Some(token)
108    } else {
109        None
110    }
111}
112
113/// Calculate the cursor position within a token for syntax highlighting
114#[must_use]
115pub fn get_cursor_token_position(sql_text: &str, cursor_pos: usize) -> (usize, usize) {
116    if let Some(token) = get_token_at_cursor(sql_text, cursor_pos) {
117        // Find where this token starts in the text
118        let before_cursor = &sql_text[..cursor_pos.min(sql_text.len())];
119        if let Some(rev_pos) = before_cursor.rfind(&token) {
120            let token_start = rev_pos;
121            let pos_in_token = cursor_pos.saturating_sub(token_start);
122            return (token_start, pos_in_token);
123        }
124    }
125    (cursor_pos, 0)
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_extract_partial_word() {
134        assert_eq!(
135            extract_partial_word_at_cursor("SELECT coun", 11),
136            Some("coun".to_string())
137        );
138
139        assert_eq!(
140            extract_partial_word_at_cursor("SELECT \"quoted col", 18),
141            Some("\"quoted col".to_string())
142        );
143
144        assert_eq!(extract_partial_word_at_cursor("", 0), None);
145    }
146
147    #[test]
148    fn test_get_token_at_cursor() {
149        assert_eq!(
150            get_token_at_cursor("SELECT column_name FROM", 10),
151            Some("column_name".to_string())
152        );
153
154        assert_eq!(
155            get_token_at_cursor("WHERE id = 123", 7),
156            Some("id".to_string())
157        );
158    }
159}