sql_cli/sql/
cursor_aware_parser.rs

1use crate::parser::{ParseState, Schema};
2use crate::recursive_parser::{detect_cursor_context, CursorContext, LogicalOp};
3
4#[derive(Debug, Clone)]
5pub struct CursorAwareParser {
6    schema: Schema,
7}
8
9#[derive(Debug)]
10pub struct ParseResult {
11    pub suggestions: Vec<String>,
12    pub context: String,
13    pub partial_word: Option<String>,
14}
15
16impl CursorAwareParser {
17    pub fn new() -> Self {
18        Self {
19            schema: Schema::new(),
20        }
21    }
22
23    pub fn set_schema(&mut self, schema: Schema) {
24        self.schema = schema;
25    }
26
27    pub fn update_single_table(&mut self, table_name: String, columns: Vec<String>) {
28        self.schema.set_single_table(table_name, columns);
29    }
30
31    pub fn get_table_columns(&self, table_name: &str) -> Vec<String> {
32        self.schema.get_columns(table_name)
33    }
34
35    pub fn get_completions(&self, query: &str, cursor_pos: usize) -> ParseResult {
36        // Use the recursive parser for better context detection
37        let (cursor_context, partial_word) = detect_cursor_context(query, cursor_pos);
38
39        // If we didn't get a partial word from recursive parser, try our own extraction
40        let partial_word = partial_word.or_else(|| self.extract_word_at_cursor(query, cursor_pos));
41
42        let default_table = self.schema.get_first_table_name().unwrap_or("trade_deal");
43
44        let (suggestions, context_str) = match &cursor_context {
45            CursorContext::SelectClause => {
46                // get_columns already applies quote_if_needed, so don't double-quote
47                let mut cols = self.schema.get_columns(default_table);
48                cols.push("*".to_string());
49
50                // Add math functions
51                cols.extend(vec![
52                    "ROUND(".to_string(),
53                    "ABS(".to_string(),
54                    "FLOOR(".to_string(),
55                    "CEILING(".to_string(),
56                    "CEIL(".to_string(),
57                    "MOD(".to_string(),
58                    "QUOTIENT(".to_string(),
59                    "POWER(".to_string(),
60                    "POW(".to_string(),
61                    "SQRT(".to_string(),
62                    "EXP(".to_string(),
63                    "LN(".to_string(),
64                    "LOG(".to_string(),
65                    "LOG10(".to_string(),
66                    "PI(".to_string(),
67                    "TEXTJOIN(".to_string(),
68                    "DATEDIFF(".to_string(),
69                    "DATEADD(".to_string(),
70                    "NOW(".to_string(),
71                    "TODAY(".to_string(),
72                ]);
73
74                // NOTE: We intentionally do NOT filter out already selected columns
75                // Users may want to select the same column multiple times, especially
76                // when using it in computed expressions like: SELECT q * p as notional, q
77                // Duplicate handling should be done at query execution time if needed
78
79                (cols, "SelectClause".to_string())
80            }
81            CursorContext::FromClause => {
82                let tables = self.schema.get_table_names();
83                (tables, "FromClause".to_string())
84            }
85            CursorContext::WhereClause | CursorContext::AfterLogicalOp(_) => {
86                // We're in WHERE clause or after AND/OR - suggest columns
87                let mut suggestions = self.schema.get_columns(default_table);
88
89                // Add math functions that can be used in WHERE
90                suggestions.extend(vec![
91                    "ROUND(".to_string(),
92                    "ABS(".to_string(),
93                    "FLOOR(".to_string(),
94                    "CEILING(".to_string(),
95                    "CEIL(".to_string(),
96                    "MOD(".to_string(),
97                    "QUOTIENT(".to_string(),
98                    "POWER(".to_string(),
99                    "POW(".to_string(),
100                    "SQRT(".to_string(),
101                    "EXP(".to_string(),
102                    "LN(".to_string(),
103                    "LOG(".to_string(),
104                    "LOG10(".to_string(),
105                    "PI(".to_string(),
106                    "TEXTJOIN(".to_string(),
107                    "DATEDIFF(".to_string(),
108                    "DATEADD(".to_string(),
109                    "NOW(".to_string(),
110                    "TODAY(".to_string(),
111                ]);
112
113                // Only add SQL keywords if no partial word or if partial doesn't match any columns
114                let add_keywords = if let Some(ref partial) = partial_word {
115                    let partial_lower = partial.to_lowercase();
116                    !suggestions
117                        .iter()
118                        .any(|col| col.to_lowercase().starts_with(&partial_lower))
119                } else {
120                    true
121                };
122
123                if add_keywords {
124                    suggestions.extend(vec![
125                        "AND".to_string(),
126                        "OR".to_string(),
127                        "IN".to_string(),
128                        "ORDER BY".to_string(),
129                    ]);
130                }
131
132                let ctx = match &cursor_context {
133                    CursorContext::AfterLogicalOp(LogicalOp::And) => "AfterAND",
134                    CursorContext::AfterLogicalOp(LogicalOp::Or) => "AfterOR",
135                    _ => "WhereClause",
136                };
137                (suggestions, ctx.to_string())
138            }
139            CursorContext::AfterColumn(col_name) => {
140                // We're after a column and possibly a dot (method call context)
141                let property_type = self
142                    .get_property_type(col_name)
143                    .unwrap_or("string".to_string());
144                let suggestions = self.get_string_method_suggestions(&property_type, &partial_word);
145                (suggestions, "AfterColumn".to_string())
146            }
147            CursorContext::AfterComparisonOp(col_name, op) => {
148                // We're after a comparison operator - suggest based on column type
149                let property_type = self
150                    .get_property_type(&col_name)
151                    .unwrap_or("string".to_string());
152                let suggestions = match property_type.as_str() {
153                    "datetime" => {
154                        // For datetime columns, suggest DateTime constructor
155                        let mut suggestions = vec!["DateTime(".to_string()];
156                        // Also suggest common date patterns
157                        suggestions.extend(vec![
158                            "DateTime.Today".to_string(),
159                            "DateTime.Now".to_string(),
160                        ]);
161                        suggestions
162                    }
163                    "string" => {
164                        // For strings, suggest string literals
165                        vec!["''".to_string()]
166                    }
167                    "numeric" => {
168                        // For numbers, no specific suggestions
169                        vec![]
170                    }
171                    _ => vec![],
172                };
173                (suggestions, format!("AfterComparison({} {})", col_name, op))
174            }
175            CursorContext::InMethodCall(obj, method) => {
176                let property_type = self.get_property_type(obj).unwrap_or("string".to_string());
177                let suggestions = self.get_string_method_suggestions(&property_type, &partial_word);
178                (suggestions, format!("InMethodCall({}.{})", obj, method))
179            }
180            CursorContext::InExpression => {
181                // Generic expression context - could be anywhere
182                let mut suggestions = self.schema.get_columns(default_table);
183
184                // Add math functions
185                suggestions.extend(vec![
186                    "ROUND(".to_string(),
187                    "ABS(".to_string(),
188                    "FLOOR(".to_string(),
189                    "CEILING(".to_string(),
190                    "CEIL(".to_string(),
191                    "MOD(".to_string(),
192                    "QUOTIENT(".to_string(),
193                    "POWER(".to_string(),
194                    "POW(".to_string(),
195                    "SQRT(".to_string(),
196                    "EXP(".to_string(),
197                    "LN(".to_string(),
198                    "LOG(".to_string(),
199                    "LOG10(".to_string(),
200                    "PI(".to_string(),
201                    "AND".to_string(),
202                    "OR".to_string(),
203                ]);
204                (suggestions, "InExpression".to_string())
205            }
206            CursorContext::OrderByClause => {
207                // We're in ORDER BY clause - suggest selected columns if explicit, otherwise all columns
208                let mut suggestions = Vec::new();
209
210                // Extract selected columns from the query
211                let selected_columns = self.extract_selected_columns(query, query.len());
212
213                // If we have explicitly selected columns (not SELECT *), use those
214                if !selected_columns.is_empty() && !selected_columns.contains(&"*".to_string()) {
215                    suggestions.extend(selected_columns);
216                } else {
217                    // Fallback to all columns if SELECT * or no columns detected
218                    // get_columns already applies quote_if_needed
219                    suggestions.extend(self.schema.get_columns(default_table));
220                }
221
222                // Always add ASC/DESC options
223                suggestions.extend(vec!["ASC".to_string(), "DESC".to_string()]);
224                (suggestions, "OrderByClause".to_string())
225            }
226            CursorContext::Unknown => {
227                // Fall back to original heuristic parser
228                // Ensure we slice at a valid UTF-8 character boundary
229                let safe_cursor_pos = self.find_safe_boundary(query, cursor_pos.min(query.len()));
230                let query_before_cursor = &query[..safe_cursor_pos];
231                let context = self.determine_context(query_before_cursor);
232                let suggestions = self.get_suggestions_for_context(&context, &partial_word, query);
233                return ParseResult {
234                    suggestions,
235                    context: format!("{:?} (partial: {:?})", context, partial_word),
236                    partial_word,
237                };
238            }
239        };
240
241        // Filter by partial word if present (but not for method suggestions as they're already filtered)
242        let mut final_suggestions = suggestions;
243        let is_method_context = matches!(
244            cursor_context,
245            CursorContext::AfterColumn(_)
246                | CursorContext::InMethodCall(_, _)
247                | CursorContext::AfterComparisonOp(_, _)
248        );
249
250        if let Some(ref partial) = partial_word {
251            if !is_method_context {
252                // Only filter non-method suggestions
253                final_suggestions.retain(|suggestion| {
254                    // Check if we're dealing with a partial quoted identifier
255                    if partial.starts_with('"') {
256                        // User is typing a quoted identifier like "customer
257                        let partial_without_quote = &partial[1..]; // Remove the opening quote
258
259                        // Check if suggestion is a quoted identifier that matches
260                        if suggestion.starts_with('"')
261                            && suggestion.ends_with('"')
262                            && suggestion.len() > 2
263                        {
264                            // Full quoted identifier like "Customer Id"
265                            let suggestion_without_quotes = &suggestion[1..suggestion.len() - 1];
266                            suggestion_without_quotes
267                                .to_lowercase()
268                                .starts_with(&partial_without_quote.to_lowercase())
269                        } else if suggestion.starts_with('"') && suggestion.len() > 1 {
270                            // Partial quoted identifier (shouldn't happen in suggestions but handle it)
271                            let suggestion_without_quote = &suggestion[1..];
272                            suggestion_without_quote
273                                .to_lowercase()
274                                .starts_with(&partial_without_quote.to_lowercase())
275                        } else {
276                            // Also check non-quoted suggestions that might need quotes
277                            suggestion
278                                .to_lowercase()
279                                .starts_with(&partial_without_quote.to_lowercase())
280                        }
281                    } else {
282                        // Normal non-quoted partial (e.g., "customer")
283                        // Handle quoted column names - check if the suggestion starts with a quote
284                        let suggestion_to_check = if suggestion.starts_with('"')
285                            && suggestion.ends_with('"')
286                            && suggestion.len() > 2
287                        {
288                            // Remove both quotes for comparison (e.g., "Customer Id" -> "Customer Id")
289                            &suggestion[1..suggestion.len() - 1]
290                        } else if suggestion.starts_with('"') && suggestion.len() > 1 {
291                            // Malformed quoted identifier - just strip opening quote
292                            &suggestion[1..]
293                        } else {
294                            suggestion
295                        };
296
297                        // Now compare the cleaned suggestion with the partial
298                        suggestion_to_check
299                            .to_lowercase()
300                            .starts_with(&partial.to_lowercase())
301                    }
302                });
303            }
304        }
305
306        ParseResult {
307            suggestions: final_suggestions,
308            context: format!("{} (partial: {:?})", context_str, partial_word),
309            partial_word,
310        }
311    }
312
313    fn extract_word_at_cursor(&self, query: &str, cursor_pos: usize) -> Option<String> {
314        if cursor_pos == 0 || cursor_pos > query.len() {
315            return None;
316        }
317
318        let chars: Vec<char> = query.chars().collect();
319
320        // Find word boundaries around cursor
321        let mut start = cursor_pos;
322        let mut end = cursor_pos;
323
324        // Move start backward to beginning of word
325        while start > 0 && Self::is_word_char(chars.get(start - 1).copied().unwrap_or(' ')) {
326            start -= 1;
327        }
328
329        // Move end forward to end of word
330        while end < chars.len() && Self::is_word_char(chars.get(end).copied().unwrap_or(' ')) {
331            end += 1;
332        }
333
334        // Handle both cases: cursor in middle of word or at end of word
335        if start < end {
336            // Extract partial word up to cursor
337            let partial: String = chars[start..cursor_pos.min(end)].iter().collect();
338            if !partial.is_empty() {
339                Some(partial)
340            } else {
341                None
342            }
343        } else {
344            None
345        }
346    }
347
348    fn is_word_char(ch: char) -> bool {
349        ch.is_alphanumeric() || ch == '_'
350    }
351
352    fn determine_context(&self, query_before_cursor: &str) -> ParseState {
353        let query_upper = query_before_cursor.to_uppercase();
354
355        // Check if we're at the end after a logical operator (AND/OR)
356        // This indicates we should be expecting a new column/condition
357        let trimmed = query_before_cursor.trim();
358        // Removed debug output to avoid corrupting TUI
359
360        // Check various ways AND/OR might appear at the end
361        let upper_trimmed = trimmed.to_uppercase();
362        let ends_with_and_or = upper_trimmed.ends_with(" AND") || 
363                               upper_trimmed.ends_with(" OR") ||
364                               upper_trimmed.ends_with(" AND ") ||  // With trailing space
365                               upper_trimmed.ends_with(" OR "); // With trailing space
366
367        // Also check if the last word is AND/OR
368        let words_check: Vec<&str> = query_upper.split_whitespace().collect();
369        let last_word_is_and_or = words_check
370            .last()
371            .map(|w| *w == "AND" || *w == "OR")
372            .unwrap_or(false);
373
374        if ends_with_and_or || last_word_is_and_or {
375            // After AND/OR, we're expecting a new column in WHERE context
376            if query_upper.contains("WHERE") {
377                // Detected AND/OR at end, return InWhere for column suggestions
378                return ParseState::InWhere;
379            }
380        }
381
382        let words: Vec<&str> = query_upper.split_whitespace().collect();
383
384        if words.is_empty() {
385            return ParseState::Start;
386        }
387
388        // Find the last complete SQL keyword
389        let mut last_keyword_idx = None;
390        let mut last_keyword = "";
391
392        for (i, word) in words.iter().enumerate() {
393            match *word {
394                "SELECT" => {
395                    last_keyword_idx = Some(i);
396                    last_keyword = "SELECT";
397                }
398                "FROM" => {
399                    last_keyword_idx = Some(i);
400                    last_keyword = "FROM";
401                }
402                "WHERE" => {
403                    last_keyword_idx = Some(i);
404                    last_keyword = "WHERE";
405                }
406                "AND" | "OR" => {
407                    // AND/OR continue the current WHERE context
408                    if last_keyword == "WHERE" {
409                        last_keyword_idx = Some(i);
410                        last_keyword = "WHERE"; // Stay in WHERE context
411                    }
412                }
413                "IN" => {
414                    // IN continues WHERE context
415                    if last_keyword == "WHERE" {
416                        last_keyword_idx = Some(i);
417                        last_keyword = "WHERE";
418                    }
419                }
420                "ORDER" => {
421                    // Check if followed by BY
422                    if i + 1 < words.len() && words[i + 1] == "BY" {
423                        last_keyword_idx = Some(i);
424                        last_keyword = "ORDER BY";
425                    }
426                }
427                _ => {}
428            }
429        }
430
431        match last_keyword {
432            "SELECT" => {
433                if let Some(idx) = last_keyword_idx {
434                    // Count tokens after SELECT
435                    let tokens_after_select = words.len() - idx - 1;
436                    if tokens_after_select == 0 {
437                        ParseState::AfterSelect
438                    } else {
439                        // Check if we've seen FROM yet
440                        if words[(idx + 1)..].contains(&"FROM") {
441                            ParseState::AfterTable // We're past the FROM clause
442                        } else {
443                            ParseState::InColumnList
444                        }
445                    }
446                } else {
447                    ParseState::AfterSelect
448                }
449            }
450            "FROM" => {
451                if let Some(idx) = last_keyword_idx {
452                    let tokens_after_from = words.len() - idx - 1;
453                    if tokens_after_from == 0 {
454                        ParseState::AfterFrom
455                    } else {
456                        ParseState::AfterTable
457                    }
458                } else {
459                    ParseState::AfterFrom
460                }
461            }
462            "WHERE" => ParseState::InWhere,
463            "ORDER BY" => ParseState::InOrderBy,
464            _ => {
465                // No clear keyword found, try to infer from context
466                if query_upper.contains("SELECT")
467                    && query_upper.contains("FROM")
468                    && query_upper.contains("WHERE")
469                {
470                    ParseState::InWhere
471                } else if query_upper.contains("SELECT") && query_upper.contains("FROM") {
472                    ParseState::AfterTable
473                } else if query_upper.contains("SELECT") {
474                    ParseState::InColumnList
475                } else {
476                    ParseState::Start
477                }
478            }
479        }
480    }
481
482    fn get_suggestions_for_context(
483        &self,
484        context: &ParseState,
485        partial_word: &Option<String>,
486        query: &str,
487    ) -> Vec<String> {
488        let default_table = self.schema.get_first_table_name().unwrap_or("trade_deal");
489
490        let mut suggestions = match context {
491            ParseState::Start => vec!["SELECT".to_string()],
492            ParseState::AfterSelect => {
493                let mut cols = self.schema.get_columns(default_table);
494                cols.push("*".to_string());
495                cols
496            }
497            ParseState::InColumnList => {
498                let mut cols = self.schema.get_columns(default_table);
499                cols.push("FROM".to_string());
500                cols
501            }
502            ParseState::AfterFrom => self.schema.get_table_names(),
503            ParseState::AfterTable => {
504                vec!["WHERE".to_string(), "ORDER BY".to_string()]
505            }
506            ParseState::InWhere => {
507                // Prioritize column names over SQL keywords in WHERE clauses
508                let mut suggestions = self.schema.get_columns(default_table);
509
510                // Only add SQL keywords if no partial word or if partial doesn't match any columns
511                let add_keywords = if let Some(partial) = partial_word {
512                    let partial_lower = partial.to_lowercase();
513                    let matching_columns = suggestions
514                        .iter()
515                        .any(|col| col.to_lowercase().starts_with(&partial_lower));
516                    !matching_columns // Only add keywords if no columns match
517                } else {
518                    true // Add keywords when no partial word
519                };
520
521                if add_keywords {
522                    suggestions.extend(vec![
523                        "AND".to_string(),
524                        "OR".to_string(),
525                        "IN".to_string(),
526                        "ORDER BY".to_string(),
527                    ]);
528                }
529
530                suggestions
531            }
532            ParseState::InOrderBy => {
533                let mut suggestions = Vec::new();
534
535                // Extract selected columns from the query
536                let selected_columns = self.extract_selected_columns(query, query.len());
537
538                // If we have explicitly selected columns (not SELECT *), use those
539                if !selected_columns.is_empty() && !selected_columns.contains(&"*".to_string()) {
540                    suggestions.extend(selected_columns);
541                } else {
542                    // Fallback to all columns if SELECT * or no columns detected
543                    suggestions.extend(self.schema.get_columns(default_table));
544                }
545
546                // Always add ASC/DESC options
547                suggestions.extend(vec!["ASC".to_string(), "DESC".to_string()]);
548                suggestions
549            }
550            _ => vec![],
551        };
552
553        // Filter by partial word if present
554        if let Some(partial) = partial_word {
555            suggestions.retain(|suggestion| {
556                suggestion
557                    .to_lowercase()
558                    .starts_with(&partial.to_lowercase())
559            });
560        }
561
562        suggestions
563    }
564
565    fn extract_selected_columns(&self, query: &str, cursor_pos: usize) -> Vec<String> {
566        // Extract columns that have already been selected in the current SELECT clause
567        let mut selected_columns = Vec::new();
568
569        // Find the SELECT keyword position
570        let query_upper = query.to_uppercase();
571        if let Some(select_pos) = query_upper.find("SELECT") {
572            // Find the FROM keyword or cursor position, whichever comes first
573            let end_pos = query_upper
574                .find("FROM")
575                .unwrap_or(cursor_pos)
576                .min(cursor_pos);
577
578            // Extract the SELECT clause
579            if select_pos + 6 < end_pos {
580                let select_clause = &query[(select_pos + 6)..end_pos];
581
582                // Split by commas and extract column names
583                for part in select_clause.split(',') {
584                    let trimmed = part.trim();
585                    if !trimmed.is_empty() {
586                        // Extract just the column name (handle cases like "column AS alias")
587                        let col_name = if trimmed.starts_with('"') {
588                            // Handle quoted identifiers - find the closing quote
589                            if let Some(close_quote_pos) = trimmed[1..].find('"') {
590                                // Include both quotes
591                                &trimmed[..close_quote_pos + 2]
592                            } else {
593                                // Malformed quoted identifier, take what we have
594                                trimmed
595                            }
596                        } else {
597                            // For unquoted identifiers, stop at first whitespace
598                            if let Some(space_pos) = trimmed.find(char::is_whitespace) {
599                                &trimmed[..space_pos]
600                            } else {
601                                trimmed
602                            }
603                        };
604
605                        // Preserve the original case of the column name
606                        selected_columns.push(col_name.to_string());
607                    }
608                }
609            }
610        }
611
612        selected_columns
613    }
614
615    fn detect_method_call_context(
616        &self,
617        query_before_cursor: &str,
618        _cursor_pos: usize,
619    ) -> Option<(String, String)> {
620        // Look for pattern: "propertyName." at the end of the query before cursor
621        // This handles cases like "WHERE platformOrderId." or "SELECT COUNT(*) WHERE ticker."
622        // But NOT cases like "WHERE prop.Contains('x') AND " where we've moved past the method call
623
624        // Find the last dot before cursor
625        if let Some(dot_pos) = query_before_cursor.rfind('.') {
626            // Check if cursor is close to the dot - if there's too much text after the dot,
627            // we're probably not in method call context anymore
628            let text_after_dot = &query_before_cursor[dot_pos + 1..];
629
630            // If there's significant text after the dot that looks like a completed method call,
631            // we're probably not in method call context
632            if text_after_dot.contains(')')
633                && (text_after_dot.contains(" AND ")
634                    || text_after_dot.contains(" OR ")
635                    || text_after_dot.trim().ends_with(" AND")
636                    || text_after_dot.trim().ends_with(" OR"))
637            {
638                return None; // We've completed the method call and moved on
639            }
640
641            // Extract the word immediately before the dot
642            let before_dot = &query_before_cursor[..dot_pos];
643
644            // Find the start of the property name (going backwards from dot)
645            let mut property_start = dot_pos;
646            let chars: Vec<char> = before_dot.chars().collect();
647
648            while property_start > 0 {
649                let char_pos = property_start - 1;
650                if char_pos < chars.len() {
651                    let ch = chars[char_pos];
652                    if ch.is_alphanumeric() || ch == '_' {
653                        property_start -= 1;
654                    } else {
655                        break;
656                    }
657                } else {
658                    break;
659                }
660            }
661
662            if property_start < dot_pos {
663                let property_name = before_dot[property_start..].trim().to_string();
664
665                // Check if this property exists in our schema and get its type
666                if let Some(property_type) = self.get_property_type(&property_name) {
667                    return Some((property_name, property_type));
668                }
669            }
670        }
671
672        None
673    }
674
675    fn get_property_type(&self, property_name: &str) -> Option<String> {
676        // Get property type from schema - for now, we'll use a simple mapping
677        // In a more sophisticated implementation, this would query the actual schema
678
679        let property_lower = property_name.to_lowercase();
680
681        // String properties (most common for Dynamic LINQ operations)
682        let string_properties = [
683            "platformorderid",
684            "dealid",
685            "externalorderid",
686            "parentorderid",
687            "instrumentid",
688            "instrumentname",
689            "instrumenttype",
690            "isin",
691            "cusip",
692            "ticker",
693            "exchange",
694            "counterparty",
695            "counterpartyid",
696            "counterpartytype",
697            "counterpartycountry",
698            "trader",
699            "portfolio",
700            "strategy",
701            "desk",
702            "status",
703            "confirmationstatus",
704            "settlementstatus",
705            "allocationstatus",
706            "currency",
707            "side",
708            "producttype",
709            "venue",
710            "clearinghouse",
711            "prime",
712            "comments",
713            "book",
714            "source",
715            "sourcesystem",
716        ];
717
718        // Numeric properties
719        let numeric_properties = [
720            "price",
721            "quantity",
722            "notional",
723            "commission",
724            "accrual",
725            "netamount",
726            "accruedinterest",
727            "grossamount",
728            "settlementamount",
729            "fees",
730            "tax",
731        ];
732
733        // DateTime properties
734        let datetime_properties = [
735            "tradedate",
736            "settlementdate",
737            "createddate",
738            "modifieddate",
739            "valuedate",
740            "maturitydate",
741            "confirmationdate",
742            "executiondate",
743            "lastmodifieddate",
744        ];
745
746        if string_properties.contains(&property_lower.as_str()) {
747            Some("string".to_string())
748        } else if numeric_properties.contains(&property_lower.as_str()) {
749            Some("numeric".to_string())
750        } else if datetime_properties.contains(&property_lower.as_str()) {
751            Some("datetime".to_string())
752        } else {
753            // Default to string for unknown properties
754            Some("string".to_string())
755        }
756    }
757
758    /// Find a safe UTF-8 character boundary at or before the given position
759    fn find_safe_boundary(&self, s: &str, pos: usize) -> usize {
760        if pos >= s.len() {
761            return s.len();
762        }
763
764        // If already at a valid boundary, return it
765        if s.is_char_boundary(pos) {
766            return pos;
767        }
768
769        // Find the nearest valid character boundary before pos
770        let mut safe_pos = pos;
771        while safe_pos > 0 && !s.is_char_boundary(safe_pos) {
772            safe_pos -= 1;
773        }
774        safe_pos
775    }
776
777    #[cfg(test)]
778    pub fn test_extract_selected_columns(&self, query: &str, cursor_pos: usize) -> Vec<String> {
779        self.extract_selected_columns(query, cursor_pos)
780    }
781
782    fn get_string_method_suggestions(
783        &self,
784        property_type: &str,
785        partial_word: &Option<String>,
786    ) -> Vec<String> {
787        let mut suggestions = Vec::new();
788
789        match property_type {
790            "string" => {
791                // Common Dynamic LINQ string methods
792                // Format: methods with parameters include ('') with cursor placement hint
793                // Methods without parameters include () for consistency
794                let string_methods = vec![
795                    "Contains('')",
796                    "StartsWith('')",
797                    "EndsWith('')",
798                    "IndexOf('')",
799                    "Substring(0, 5)",
800                    "ToLower()",
801                    "ToUpper()",
802                    "Trim()",
803                    "TrimStart()",
804                    "TrimEnd()",
805                    "IsNullOrEmpty()",
806                    "Replace('', '')",
807                    "Length()", // Changed from "Length" to "Length()"
808                ];
809
810                if let Some(partial) = partial_word {
811                    let partial_lower = partial.to_lowercase();
812                    for method in string_methods {
813                        if method.to_lowercase().starts_with(&partial_lower) {
814                            suggestions.push(method.to_string());
815                        }
816                    }
817                } else {
818                    suggestions.extend(string_methods.into_iter().map(|s| s.to_string()));
819                }
820            }
821            "numeric" | "integer" | "float" | "decimal" => {
822                // Numeric columns can use string methods via type coercion in the tree walker
823                let numeric_string_methods = vec![
824                    "Contains('')",
825                    "StartsWith('')",
826                    "EndsWith('')",
827                    "ToString()",
828                    "Length()", // Changed from "Length" to "Length()"
829                                // Could add math methods here in the future
830                ];
831
832                if let Some(partial) = partial_word {
833                    let partial_lower = partial.to_lowercase();
834                    for method in numeric_string_methods {
835                        if method.to_lowercase().starts_with(&partial_lower) {
836                            suggestions.push(method.to_string());
837                        }
838                    }
839                } else {
840                    suggestions.extend(numeric_string_methods.into_iter().map(|s| s.to_string()));
841                }
842            }
843            "datetime" => {
844                // DateTime columns can use both datetime-specific and string methods
845                let datetime_methods = vec![
846                    "Year()",  // Changed from "Year" to "Year()"
847                    "Month()", // Changed from "Month" to "Month()"
848                    "Day()",   // Changed from "Day" to "Day()"
849                    "ToString(\"yyyy-MM-dd\")",
850                    "AddDays(1)",
851                    // String methods via coercion
852                    "Contains('')",
853                    "StartsWith('')",
854                    "EndsWith('')",
855                    "Length()", // Changed from "Length" to "Length()"
856                ];
857
858                if let Some(partial) = partial_word {
859                    let partial_lower = partial.to_lowercase();
860                    for method in datetime_methods {
861                        if method.to_lowercase().starts_with(&partial_lower) {
862                            suggestions.push(method.to_string());
863                        }
864                    }
865                } else {
866                    suggestions.extend(datetime_methods.into_iter().map(|s| s.to_string()));
867                }
868            }
869            _ => {
870                // Default to string methods
871                suggestions.push("ToString()".to_string());
872            }
873        }
874
875        suggestions
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882
883    fn create_test_parser() -> CursorAwareParser {
884        CursorAwareParser::new()
885    }
886
887    #[test]
888    fn test_basic_select_completion() {
889        let parser = create_test_parser();
890
891        // At the beginning
892        let result = parser.get_completions("", 0);
893        println!("Context for empty query: {}", result.context);
894        assert_eq!(result.suggestions, vec!["SELECT"]);
895        assert!(result.context.contains("Start") || result.context.contains("Unknown"));
896
897        // After SELECT
898        let result = parser.get_completions("SELECT ", 7);
899        println!("Context for 'SELECT ': {}", result.context);
900        assert!(result.suggestions.contains(&"*".to_string()));
901        assert!(result.suggestions.contains(&"dealId".to_string()));
902        assert!(result.context.contains("AfterSelect") || result.context.contains("SelectClause"));
903    }
904
905    #[test]
906    fn test_where_clause_completion() {
907        let parser = create_test_parser();
908
909        // After WHERE
910        let query = "SELECT * FROM trade_deal WHERE ";
911        let result = parser.get_completions(query, query.len());
912        println!("Context for WHERE clause: {}", result.context);
913        assert!(result.suggestions.contains(&"dealId".to_string()));
914        assert!(result.suggestions.contains(&"platformOrderId".to_string()));
915        assert!(result.context.contains("InWhere") || result.context.contains("WhereClause"));
916    }
917
918    #[test]
919    fn test_method_call_detection() {
920        let parser = create_test_parser();
921
922        // After column name with dot
923        let query = "SELECT * FROM trade_deal WHERE platformOrderId.";
924        let result = parser.get_completions(query, query.len());
925        println!("Context for method call: {}", result.context);
926        println!("Suggestions: {:?}", result.suggestions);
927        assert!(result.suggestions.contains(&"Contains('')".to_string()));
928        assert!(result.suggestions.contains(&"StartsWith('')".to_string()));
929        assert!(result.context.contains("MethodCall") || result.context.contains("AfterColumn"));
930    }
931
932    #[test]
933    fn test_and_operator_context() {
934        let parser = create_test_parser();
935
936        // After completed method call and AND
937        let query = "SELECT * FROM trade_deal WHERE allocationStatus.Contains(\"All\") AND ";
938        let result = parser.get_completions(query, query.len());
939        println!("Context after AND: {}", result.context);
940        assert!(result.suggestions.contains(&"dealId".to_string()));
941        assert!(result.suggestions.contains(&"platformOrderId".to_string()));
942        assert!(
943            result.context.contains("InWhere")
944                || result.context.contains("AfterAND")
945                || result.context.contains("WhereClause")
946        );
947        assert!(!result.context.contains("MethodCall"));
948    }
949
950    #[test]
951    fn test_and_operator_with_partial_word() {
952        let parser = create_test_parser();
953
954        // After AND with partial column name
955        let query = "SELECT * FROM trade_deal WHERE allocationStatus.Contains(\"All\") AND p";
956        let result = parser.get_completions(query, query.len());
957
958        // Should suggest columns starting with 'p'
959        assert!(result.suggestions.contains(&"platformOrderId".to_string()));
960        assert!(result.suggestions.contains(&"price".to_string()));
961        assert!(result.suggestions.contains(&"portfolio".to_string()));
962
963        // Should NOT suggest columns that don't start with 'p'
964        assert!(!result.suggestions.contains(&"dealId".to_string()));
965        assert!(!result.suggestions.contains(&"quantity".to_string()));
966
967        // Should be in WHERE context, not MethodCall
968        assert!(
969            result.context.contains("InWhere")
970                || result.context.contains("WhereClause")
971                || result.context.contains("AfterAND")
972        );
973        assert!(!result.context.contains("MethodCall"));
974
975        // Should have detected partial word
976        assert!(result.context.contains("(partial: Some(\"p\"))"));
977    }
978
979    #[test]
980    fn test_or_operator_context() {
981        let parser = create_test_parser();
982
983        // After OR
984        let query = "SELECT * FROM trade_deal WHERE price > 100 OR ";
985        let result = parser.get_completions(query, query.len());
986        println!("Context after OR: {}", result.context);
987        assert!(result.suggestions.contains(&"dealId".to_string()));
988        assert!(
989            result.context.contains("InWhere")
990                || result.context.contains("AfterOR")
991                || result.context.contains("WhereClause")
992        );
993    }
994
995    #[test]
996    fn test_partial_word_extraction() {
997        let parser = create_test_parser();
998
999        // Test various partial word scenarios
1000        assert_eq!(
1001            parser.extract_word_at_cursor("SELECT deal", 11),
1002            Some("deal".to_string())
1003        );
1004        assert_eq!(
1005            parser.extract_word_at_cursor("WHERE p", 7),
1006            Some("p".to_string())
1007        );
1008        assert_eq!(
1009            parser.extract_word_at_cursor("AND platf", 9),
1010            Some("platf".to_string())
1011        );
1012
1013        // Edge cases
1014        assert_eq!(parser.extract_word_at_cursor("", 0), None);
1015        assert_eq!(parser.extract_word_at_cursor("SELECT ", 7), None);
1016    }
1017
1018    #[test]
1019    fn test_complex_query_with_multiple_conditions() {
1020        let parser = create_test_parser();
1021
1022        // Complex query with multiple ANDs
1023        let query = "SELECT * FROM trade_deal WHERE platformOrderId.StartsWith(\"ABC\") AND price > 100 AND ";
1024        let result = parser.get_completions(query, query.len());
1025        println!("Context for complex query: {}", result.context);
1026        assert!(result.suggestions.contains(&"dealId".to_string()));
1027        assert!(
1028            result.context.contains("InWhere")
1029                || result.context.contains("AfterAND")
1030                || result.context.contains("WhereClause")
1031        );
1032        assert!(!result.context.contains("MethodCall"));
1033    }
1034
1035    #[test]
1036    fn test_in_clause_support() {
1037        let parser = create_test_parser();
1038
1039        // After IN
1040        let query = "SELECT * FROM trade_deal WHERE status IN ";
1041        let result = parser.get_completions(query, query.len());
1042        println!("Context after IN: {}", result.context);
1043        // IN clause support - should suggest opening parenthesis or values
1044        assert!(
1045            result.context.contains("InWhere")
1046                || result.context.contains("WhereClause")
1047                || result.context.contains("Unknown")
1048        );
1049    }
1050
1051    #[test]
1052    fn test_partial_method_name_completion() {
1053        let parser = create_test_parser();
1054
1055        // Partial method name after dot
1056        let query = "SELECT * FROM trade_deal WHERE instrumentName.Con";
1057        let result = parser.get_completions(query, query.len());
1058        println!("Context for partial method: {}", result.context);
1059        println!("Suggestions: {:?}", result.suggestions);
1060
1061        // Should be in method call context with partial word "Con"
1062        assert!(result.context.contains("MethodCall") || result.context.contains("AfterColumn"));
1063        assert!(result.context.contains("(partial: Some(\"Con\"))"));
1064
1065        // Should suggest methods starting with "Con"
1066        assert!(result.suggestions.contains(&"Contains('')".to_string()));
1067        assert!(!result.suggestions.contains(&"StartsWith('')".to_string())); // Doesn't start with "Con"
1068    }
1069
1070    #[test]
1071    fn test_partial_matching_quoted_identifier() {
1072        let parser = CursorAwareParser::new();
1073        // Set up schema with "Customer Id" column
1074        let mut parser = parser;
1075        parser.update_single_table(
1076            "customers".to_string(),
1077            vec![
1078                "Index".to_string(),
1079                "Customer Id".to_string(), // Store without quotes
1080                "First Name".to_string(),  // Store without quotes
1081                "Company".to_string(),
1082            ],
1083        );
1084
1085        // Test that "customer" partial matches "Customer Id"
1086        let query = "SELECT customer";
1087        let result = parser.get_completions(query, query.len());
1088
1089        // Should suggest "Customer Id" (quoted)
1090        assert!(
1091            result.suggestions.iter().any(|s| s == "\"Customer Id\""),
1092            "Should suggest quoted Customer Id for partial \"customer\". Got: {:?}",
1093            result.suggestions
1094        );
1095    }
1096
1097    #[test]
1098    fn test_case_preservation_in_order_by() {
1099        let parser = CursorAwareParser::new();
1100        let mut parser = parser;
1101        parser.update_single_table(
1102            "customers".to_string(),
1103            vec!["Company".to_string(), "Country".to_string()],
1104        );
1105
1106        // Test that ORDER BY preserves case from SELECT
1107        let query = "SELECT Company, Country FROM customers ORDER BY Com";
1108        let result = parser.get_completions(query, query.len());
1109
1110        // Should suggest "Company" with proper case
1111        assert!(
1112            result.suggestions.iter().any(|s| s == "Company"),
1113            "Should preserve case in ORDER BY suggestions. Got: {:?}",
1114            result.suggestions
1115        );
1116    }
1117
1118    #[test]
1119    fn test_extract_selected_columns_preserves_case() {
1120        let parser = CursorAwareParser::new();
1121
1122        let query = "SELECT Company, Country FROM customers";
1123        let columns = parser.test_extract_selected_columns(query, query.len());
1124
1125        assert_eq!(columns, vec!["Company", "Country"]);
1126        assert_ne!(
1127            columns,
1128            vec!["company", "country"],
1129            "Should preserve original case"
1130        );
1131    }
1132
1133    #[test]
1134    fn test_filtering_already_selected_columns() {
1135        let parser = CursorAwareParser::new();
1136        let mut parser = parser;
1137        parser.update_single_table(
1138            "customers".to_string(),
1139            vec![
1140                "Company".to_string(),
1141                "Country".to_string(),
1142                "Customer Id".to_string(),
1143            ],
1144        );
1145
1146        // Already selected Company, but we SHOULD still suggest it
1147        // Users may want to select the same column multiple times
1148        // e.g., for computed expressions like: SELECT q * p as total, q
1149        let query = "SELECT Company, ";
1150        let result = parser.get_completions(query, query.len());
1151
1152        assert!(
1153            result.suggestions.iter().any(|s| s == "Company"),
1154            "Should still suggest Company even though already selected"
1155        );
1156        assert!(
1157            result.suggestions.iter().any(|s| s == "Country"),
1158            "Should suggest Country"
1159        );
1160        assert!(
1161            result.suggestions.iter().any(|s| s == "\"Customer Id\""),
1162            "Should suggest Customer Id"
1163        );
1164    }
1165
1166    #[test]
1167    fn test_order_by_completion_with_quoted_columns() {
1168        let parser = CursorAwareParser::new();
1169        let mut parser = parser;
1170        parser.update_single_table(
1171            "customers".to_string(),
1172            vec![
1173                "City".to_string(),
1174                "Company".to_string(),
1175                "Country".to_string(),
1176                "Customer Id".to_string(),
1177            ],
1178        );
1179
1180        // Test ORDER BY completion after query with quoted columns
1181        let query = r#"SELECT City,Company,Country,"Customer Id" FROM customers ORDER BY coun"#;
1182        let result = parser.get_completions(query, query.len());
1183
1184        // Should get the partial word right
1185        assert_eq!(
1186            result.partial_word,
1187            Some("coun".to_string()),
1188            "Should extract 'coun' as partial, not something weird"
1189        );
1190
1191        // Should suggest Country
1192        assert!(
1193            result.suggestions.iter().any(|s| s == "Country"),
1194            "Should suggest Country for partial 'coun'. Got: {:?}",
1195            result.suggestions
1196        );
1197    }
1198
1199    #[test]
1200    fn test_order_by_quoted_partial_completion() {
1201        let parser = CursorAwareParser::new();
1202        let mut parser = parser;
1203        parser.update_single_table(
1204            "customers".to_string(),
1205            vec![
1206                "City".to_string(),
1207                "Company".to_string(),
1208                "Country".to_string(),
1209                "Customer Id".to_string(),
1210            ],
1211        );
1212
1213        // Test ORDER BY completion with partial quoted identifier
1214        let query =
1215            r#"select City,Company,Country,"Customer Id" from customers order by City, "Customer"#;
1216        let result = parser.get_completions(query, query.len());
1217
1218        // The partial word should be "Customer
1219        assert_eq!(
1220            result.partial_word,
1221            Some("\"Customer".to_string()),
1222            "Should extract '\"Customer' as partial"
1223        );
1224
1225        // Should suggest "Customer Id" with proper quotes
1226        assert!(
1227            result.suggestions.iter().any(|s| s == "\"Customer Id\""),
1228            "Should suggest properly quoted 'Customer Id' for partial '\"Customer'. Got: {:?}",
1229            result.suggestions
1230        );
1231
1232        // Should NOT have truncated suggestions like "Customer
1233        assert!(
1234            !result.suggestions.iter().any(|s| s == "\"Customer"),
1235            "Should not have truncated suggestion '\"Customer'. Got: {:?}",
1236            result.suggestions
1237        );
1238    }
1239}