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