diff --git a/Cargo.lock b/Cargo.lock index d0eba49..ecc1ddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -810,7 +810,6 @@ dependencies = [ "csvlens", "dirs", "gumdrop", - "home 0.4.2", "once_cell", "openssl", "pest", @@ -1061,16 +1060,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "home" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "013e4e6e9134211bb4d6bf53dd8cfb75d9e2715cc33614b9c0827718c6fbe0b8" -dependencies = [ - "scopeguard", - "winapi", -] - [[package]] name = "home" version = "0.5.12" @@ -2245,7 +2234,7 @@ dependencies = [ "cfg-if", "clipboard-win 4.5.0", "fd-lock", - "home 0.5.12", + "home", "libc", "log", "memchr", diff --git a/src/args.rs b/src/args.rs index 2d2affb..77e1494 100644 --- a/src/args.rs +++ b/src/args.rs @@ -76,7 +76,6 @@ pub struct Args { #[serde(default)] pub jwt_from_file: bool, - #[options( no_short, help = "OAuth environment to use (e.g., 'app' or 'staging'). Used for Service Account authentication", @@ -113,6 +112,10 @@ pub struct Args { #[serde(skip_serializing, skip_deserializing)] pub update_defaults: bool, + #[options(no_short, help = "Disable syntax highlighting in REPL")] + #[serde(default)] + pub no_color: bool, + #[options(help = "Print version")] #[serde(default)] pub version: bool, @@ -136,7 +139,7 @@ impl Args { /// "client:auto" → "auto", "client:vertical" → "vertical", "PSQL" → "" pub fn get_display_mode(&self) -> &str { if self.format.starts_with("client:") { - &self.format[7..] // Skip "client:" prefix + &self.format[7..] // Skip "client:" prefix } else { "" } @@ -153,6 +156,22 @@ impl Args { pub fn is_auto_display(&self) -> bool { self.get_display_mode().eq_ignore_ascii_case("auto") } + + /// Determine if colors should be used for syntax highlighting + pub fn should_use_colors(&self) -> bool { + // Check NO_COLOR environment variable (standard: no-color.org) + if std::env::var("NO_COLOR").is_ok() { + return false; + } + + // Check command-line flag + if self.no_color { + return false; + } + + // Default: use colors + true + } } pub fn normalize_extras(extras: Vec, encode: bool) -> Result, Box> { @@ -281,7 +300,8 @@ pub fn get_args() -> Result> { // Warn if user specified a client format name without the "client:" prefix if args.format.eq_ignore_ascii_case("auto") || args.format.eq_ignore_ascii_case("vertical") - || args.format.eq_ignore_ascii_case("horizontal") { + || args.format.eq_ignore_ascii_case("horizontal") + { eprintln!("Warning: Format '{}' is not supported by the server.", args.format); eprintln!("Did you mean '--format client:{}'?", args.format.to_lowercase()); eprintln!("Client-side formats require the 'client:' prefix (e.g., client:auto, client:vertical, client:horizontal)"); diff --git a/src/highlight.rs b/src/highlight.rs new file mode 100644 index 0000000..dbc952b --- /dev/null +++ b/src/highlight.rs @@ -0,0 +1,321 @@ +use once_cell::sync::Lazy; +use regex::Regex; +use rustyline::highlight::Highlighter; +use std::borrow::Cow; + +/// Color scheme using ANSI escape codes +pub struct ColorScheme { + keyword: &'static str, + function: &'static str, + string: &'static str, + number: &'static str, + comment: &'static str, + operator: &'static str, + reset: &'static str, +} + +impl ColorScheme { + fn new() -> Self { + Self { + keyword: "\x1b[94m", // Bright Blue - SQL keywords stand out clearly + function: "\x1b[96m", // Bright Cyan - distinct from keywords + string: "\x1b[93m", // Bright Yellow - conventional choice (DuckDB, pgcli, VSCode) + number: "\x1b[95m", // Bright Magenta - clear distinction + comment: "\x1b[90m", // Bright Black (gray) - better visibility than dim + operator: "\x1b[0m", // Default/Reset - subtle, doesn't compete visually + reset: "\x1b[0m", // Reset All + } + } +} + +// SQL Keywords pattern +static KEYWORD_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(SELECT|FROM|WHERE|JOIN|INNER|LEFT|RIGHT|OUTER|FULL|ON|AND|OR|NOT|IN|IS|NULL|LIKE|BETWEEN|GROUP|BY|HAVING|ORDER|ASC|DESC|LIMIT|OFFSET|UNION|INTERSECT|EXCEPT|INSERT|INTO|VALUES|UPDATE|SET|DELETE|CREATE|ALTER|DROP|TABLE|VIEW|INDEX|AS|DISTINCT|ALL|CASE|WHEN|THEN|ELSE|END|WITH|RECURSIVE|RETURNING|CAST|EXTRACT|INTERVAL|EXISTS|PRIMARY|KEY|FOREIGN|REFERENCES|CONSTRAINT|DEFAULT|UNIQUE|CHECK|CROSS)\b").unwrap() +}); + +// SQL Functions pattern +static FUNCTION_PATTERN: Lazy = Lazy::new(|| { + Regex::new(r"(?i)\b(COUNT|SUM|AVG|MIN|MAX|COALESCE|NULLIF|CONCAT|SUBSTRING|LENGTH|UPPER|LOWER|TRIM|ROUND|FLOOR|CEIL|ABS|NOW|CURRENT_DATE|CURRENT_TIME|CURRENT_TIMESTAMP|DATE_TRUNC|EXTRACT)\s*\(").unwrap() +}); + +// String pattern (single quotes with escape sequences) +static STRING_PATTERN: Lazy = Lazy::new(|| Regex::new(r"'(?:[^']|''|\\')*'").unwrap()); + +// Number pattern +static NUMBER_PATTERN: Lazy = Lazy::new(|| Regex::new(r"\b\d+\.?\d*([eE][+-]?\d+)?\b").unwrap()); + +// Comment patterns +static LINE_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"--[^\n]*").unwrap()); + +static BLOCK_COMMENT_PATTERN: Lazy = Lazy::new(|| Regex::new(r"/\*[\s\S]*?\*/").unwrap()); + +// Operator pattern +static OPERATOR_PATTERN: Lazy = Lazy::new(|| Regex::new(r"[=<>!]+|[+\-*/%]|\|\||::").unwrap()); + +/// SQL syntax highlighter using regex patterns +pub struct SqlHighlighter { + color_scheme: ColorScheme, + enabled: bool, +} + +impl SqlHighlighter { + /// Create a new SQL highlighter + /// + /// # Arguments + /// * `enabled` - Whether syntax highlighting should be enabled + pub fn new(enabled: bool) -> Result> { + Ok(Self { + color_scheme: ColorScheme::new(), + enabled, + }) + } + + /// Highlight SQL text by applying ANSI color codes + fn highlight_sql(&self, line: &str) -> String { + if !self.enabled || line.is_empty() { + return line.to_string(); + } + + // Collect all matches with their positions and colors + let mut highlights: Vec<(usize, usize, &str)> = Vec::new(); + + // Match comments first (highest priority to prevent highlighting within comments) + for mat in LINE_COMMENT_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); + } + for mat in BLOCK_COMMENT_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.comment)); + } + + // Match strings (high priority to prevent highlighting keywords in strings) + for mat in STRING_PATTERN.find_iter(line) { + highlights.push((mat.start(), mat.end(), self.color_scheme.string)); + } + + // Collect other matches separately to avoid borrow checker issues + let mut keyword_matches = Vec::new(); + for mat in KEYWORD_PATTERN.find_iter(line) { + keyword_matches.push((mat.start(), mat.end())); + } + + let mut function_matches = Vec::new(); + for mat in FUNCTION_PATTERN.find_iter(line) { + // Don't include the opening parenthesis + function_matches.push((mat.start(), mat.end() - 1)); + } + + let mut number_matches = Vec::new(); + for mat in NUMBER_PATTERN.find_iter(line) { + number_matches.push((mat.start(), mat.end())); + } + + let mut operator_matches = Vec::new(); + for mat in OPERATOR_PATTERN.find_iter(line) { + operator_matches.push((mat.start(), mat.end())); + } + + // Add matches that don't overlap with existing highlights (strings/comments) + for (start, end) in keyword_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.keyword)); + } + } + + for (start, end) in function_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.function)); + } + } + + for (start, end) in number_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.number)); + } + } + + for (start, end) in operator_matches { + let overlaps = highlights + .iter() + .any(|(s, e, _)| (start >= *s && start < *e) || (end > *s && end <= *e) || (start <= *s && end >= *e)); + if !overlaps { + highlights.push((start, end, self.color_scheme.operator)); + } + } + + // Sort highlights by start position + highlights.sort_by_key(|h| h.0); + + // Build highlighted string by inserting ANSI codes + let mut result = String::with_capacity(line.len() * 2); + let mut last_pos = 0; + let reset = self.color_scheme.reset; + + for (start, end, color) in highlights { + // Skip overlapping highlights (shouldn't happen but just in case) + if start < last_pos { + continue; + } + + // Add text before highlight + if start > last_pos { + result.push_str(&line[last_pos..start]); + } + + // Add colored text + result.push_str(color); + result.push_str(&line[start..end]); + result.push_str(reset); + + last_pos = end; + } + + // Add remaining text + if last_pos < line.len() { + result.push_str(&line[last_pos..]); + } + + result + } +} + +impl Highlighter for SqlHighlighter { + fn highlight<'l>(&self, line: &'l str, _pos: usize) -> Cow<'l, str> { + if !self.enabled { + return Cow::Borrowed(line); + } + + let highlighted = self.highlight_sql(line); + if highlighted == line { + Cow::Borrowed(line) + } else { + Cow::Owned(highlighted) + } + } + + fn highlight_char(&self, _line: &str, _pos: usize) -> bool { + self.enabled + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_disabled_highlighter() { + let highlighter = SqlHighlighter::new(false).unwrap(); + let result = highlighter.highlight("SELECT * FROM users", 0); + assert_eq!(result, "SELECT * FROM users"); + } + + #[test] + fn test_keyword_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT"); + assert!(result.contains("\x1b[94m")); // Contains blue color code + assert!(result.contains("SELECT")); + assert!(result.contains("\x1b[0m")); // Contains reset code + } + + #[test] + fn test_string_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'hello'"); + assert!(result.contains("\x1b[93m")); // Contains yellow color code for string + } + + #[test] + fn test_number_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 42"); + assert!(result.contains("\x1b[95m")); // Contains magenta color code for number + } + + #[test] + fn test_comment_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("-- comment"); + assert!(result.contains("\x1b[90m")); // Contains bright black (gray) color code + } + + #[test] + fn test_function_highlighting() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT COUNT(*)"); + assert!(result.contains("\x1b[96m")); // Contains cyan color code for function + } + + #[test] + fn test_complex_query() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let query = "SELECT id, name FROM users WHERE status = 'active'"; + let result = highlighter.highlight_sql(query); + + // Should contain keyword colors (blue) + assert!(result.contains("\x1b[94m")); + // Should contain string colors (yellow) + assert!(result.contains("\x1b[93m")); + // Should contain reset codes + assert!(result.contains("\x1b[0m")); + } + + #[test] + fn test_keywords_in_strings_not_highlighted() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT 'SELECT FROM WHERE'"); + + // Count the number of keyword color codes - should only be one (for the first SELECT) + let keyword_count = result.matches("\x1b[94m").count(); + assert_eq!(keyword_count, 1); + } + + #[test] + fn test_malformed_sql_graceful() { + let highlighter = SqlHighlighter::new(true).unwrap(); + // This might not parse perfectly, but should not crash + let result = highlighter.highlight_sql("SELECT FROM WHERE"); + // Should still highlight keywords even in malformed SQL + assert!(result.contains("\x1b[94m")); + } + + #[test] + fn test_empty_string() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql(""); + assert_eq!(result, ""); + } + + #[test] + fn test_multiline_fragment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + // Each line is highlighted independently in REPL + let result = highlighter.highlight_sql("FROM users"); + assert!(result.contains("\x1b[94m")); // FROM should be highlighted + } + + #[test] + fn test_operators() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("WHERE x = 1 AND y > 2"); + // Operators are now subtle (default color) but query should highlight successfully + assert!(result.contains("\x1b[94m")); // WHERE and AND should be highlighted as keywords + assert!(result.contains("=")); // Operators should still be present + assert!(result.contains(">")); + } + + #[test] + fn test_block_comment() { + let highlighter = SqlHighlighter::new(true).unwrap(); + let result = highlighter.highlight_sql("SELECT /* comment */ 1"); + assert!(result.contains("\x1b[90m")); // Should contain bright black (gray) color code + } +} diff --git a/src/main.rs b/src/main.rs index e4b5aa8..6ddc8ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,13 @@ -use rustyline::{config::Configurer, error::ReadlineError, Cmd, DefaultEditor, EventHandler, KeyCode, KeyEvent, Modifiers}; +use rustyline::{config::Configurer, error::ReadlineError, Cmd, Editor, EventHandler, KeyCode, KeyEvent, Modifiers}; use std::io::IsTerminal; mod args; mod auth; mod context; +mod highlight; mod meta_commands; mod query; +mod repl_helper; mod table_renderer; mod utils; mod viewer; @@ -13,8 +15,10 @@ mod viewer; use args::get_args; use auth::maybe_authenticate; use context::Context; +use highlight::SqlHighlighter; use meta_commands::handle_meta_command; use query::{query, try_split_queries}; +use repl_helper::ReplHelper; use utils::history_path; use viewer::open_csvlens_viewer; @@ -47,7 +51,21 @@ async fn main() -> Result<(), Box> { let is_tty = std::io::stdout().is_terminal() && std::io::stdin().is_terminal(); - let mut rl = DefaultEditor::new()?; + // Determine if colors should be used + let should_highlight = is_tty && context.args.should_use_colors(); + + // Initialize highlighter with error handling + let highlighter = SqlHighlighter::new(should_highlight).unwrap_or_else(|e| { + if context.args.verbose { + eprintln!("Failed to initialize syntax highlighting: {}", e); + } + SqlHighlighter::new(false).unwrap() // Fallback to disabled + }); + + let helper = ReplHelper::new(highlighter); + let mut rl: Editor = Editor::new()?; + rl.set_helper(Some(helper)); + let history_path = history_path()?; rl.set_max_history_size(10_000)?; if rl.load_history(&history_path).is_err() { @@ -65,7 +83,7 @@ async fn main() -> Result<(), Box> { // Instead, we'll keep the two-step approach (Ctrl-V + Enter) which is explicit and clear rl.bind_sequence( KeyEvent(KeyCode::Char('v'), Modifiers::CTRL), - EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())) + EventHandler::Simple(Cmd::Insert(1, "\\view".to_string())), ); if is_tty && !context.args.concise { diff --git a/src/meta_commands.rs b/src/meta_commands.rs index d4f604c..bcaaaaa 100644 --- a/src/meta_commands.rs +++ b/src/meta_commands.rs @@ -1,6 +1,6 @@ use crate::context::Context; -use regex::Regex; use once_cell::sync::Lazy; +use regex::Regex; // Handle meta-commands (backslash commands) pub fn handle_meta_command(context: &mut Context, command: &str) -> Result> { @@ -45,9 +45,7 @@ pub fn handle_meta_command(context: &mut Context, command: &str) -> Result Option { - static SET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap() - }); + static SET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\set\s+(\w+)\s+(?:'([^']*)'|"([^"]*)"|(\S+))\s*$"#).unwrap()); if let Some(captures) = SET_PROMPT_RE.captures(command) { // Check if the prompt type matches @@ -70,9 +68,7 @@ fn parse_set_prompt(command: &str, prompt_type: &str) -> Option { // Generic function to parse \unset PROMPT command fn parse_unset_prompt(command: &str, prompt_type: &str) -> bool { - static UNSET_PROMPT_RE: Lazy = Lazy::new(|| { - Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap() - }); + static UNSET_PROMPT_RE: Lazy = Lazy::new(|| Regex::new(r#"(?i)^\s*\\unset\s+(\w+)\s*$"#).unwrap()); if let Some(captures) = UNSET_PROMPT_RE.captures(command) { if let Some(cmd_prompt_type) = captures.get(1) { @@ -92,7 +88,7 @@ mod tests { fn test_set_prompt1_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -103,7 +99,7 @@ mod tests { fn test_set_prompt1_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -114,7 +110,7 @@ mod tests { fn test_set_prompt1_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT1 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -125,7 +121,7 @@ mod tests { fn test_set_prompt2_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -136,7 +132,7 @@ mod tests { fn test_set_prompt2_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -147,7 +143,7 @@ mod tests { fn test_set_prompt2_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT2 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -158,7 +154,7 @@ mod tests { fn test_set_prompt3_single_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 'custom_prompt> '"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -169,7 +165,7 @@ mod tests { fn test_set_prompt3_double_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 "custom_prompt> ""#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -180,7 +176,7 @@ mod tests { fn test_set_prompt3_no_quotes() { let args = get_args().unwrap(); let mut context = Context::new(args); - + let command = r#"\set PROMPT3 custom_prompt>"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(result); @@ -191,11 +187,11 @@ mod tests { fn test_unset_prompt1() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt1("test> ".to_string()); assert_eq!(context.prompt1, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT1"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -207,11 +203,11 @@ mod tests { fn test_unset_prompt2() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt2("test> ".to_string()); assert_eq!(context.prompt2, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT2"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -223,11 +219,11 @@ mod tests { fn test_unset_prompt3() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // First set a prompt context.set_prompt3("test> ".to_string()); assert_eq!(context.prompt3, Some("test> ".to_string())); - + // Then unset it let command = r#"\unset PROMPT3"#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -239,12 +235,12 @@ mod tests { fn test_invalid_commands() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Invalid commands should return false let command = r#"\invalid command"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); - + let command = r#"\set INVALID value"#; let result = handle_meta_command(&mut context, command).unwrap(); assert!(!result); @@ -254,7 +250,7 @@ mod tests { fn test_whitespace_handling() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test with various whitespace let command = r#" \set PROMPT1 'test>' "#; let result = handle_meta_command(&mut context, command).unwrap(); @@ -266,25 +262,25 @@ mod tests { fn test_prompt_independence() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Set all three prompts to different values let command1 = r#"\set PROMPT1 'prompt1> '"#; let command2 = r#"\set PROMPT2 'prompt2> '"#; let command3 = r#"\set PROMPT3 'prompt3> '"#; - + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set independently assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, Some("prompt2> ".to_string())); assert_eq!(context.prompt3, Some("prompt3> ".to_string())); - + // Unset only PROMPT2 let unset_command = r#"\unset PROMPT2"#; handle_meta_command(&mut context, unset_command).unwrap(); - + // Verify only PROMPT2 was unset assert_eq!(context.prompt1, Some("prompt1> ".to_string())); assert_eq!(context.prompt2, None); @@ -295,16 +291,16 @@ mod tests { fn test_case_insensitive_prompt_types() { let args = get_args().unwrap(); let mut context = Context::new(args); - + // Test case insensitive prompt type matching let command1 = r#"\set prompt1 'test1> '"#; let command2 = r#"\set Prompt2 'test2> '"#; let command3 = r#"\set PROMPT3 'test3> '"#; - + handle_meta_command(&mut context, command1).unwrap(); handle_meta_command(&mut context, command2).unwrap(); handle_meta_command(&mut context, command3).unwrap(); - + // Verify all prompts are set correctly regardless of case assert_eq!(context.prompt1, Some("test1> ".to_string())); assert_eq!(context.prompt2, Some("test2> ".to_string())); diff --git a/src/repl_helper.rs b/src/repl_helper.rs new file mode 100644 index 0000000..7c48bbb --- /dev/null +++ b/src/repl_helper.rs @@ -0,0 +1,56 @@ +use crate::highlight::SqlHighlighter; +use rustyline::completion::Completer; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::Helper; +use std::borrow::Cow; + +/// REPL helper that integrates SqlHighlighter with rustyline +pub struct ReplHelper { + highlighter: SqlHighlighter, +} + +impl ReplHelper { + /// Create a new REPL helper with the given highlighter + pub fn new(highlighter: SqlHighlighter) -> Self { + Self { highlighter } + } +} + +// Implement the Helper trait (required) +impl Helper for ReplHelper {} + +// Implement empty Completer (no autocompletion for now) +impl Completer for ReplHelper { + type Candidate = String; +} + +// Implement empty Hinter (no hints for now) +impl Hinter for ReplHelper { + type Hint = String; +} + +// Implement empty Validator (accept all input) +impl Validator for ReplHelper {} + +// Delegate highlighting to SqlHighlighter +impl Highlighter for ReplHelper { + fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> { + self.highlighter.highlight(line, pos) + } + + fn highlight_char(&self, line: &str, pos: usize) -> bool { + self.highlighter.highlight_char(line, pos) + } + + fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _default: bool) -> Cow<'b, str> { + // Don't highlight the prompt itself + Cow::Borrowed(prompt) + } + + fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> { + // Don't highlight hints + Cow::Borrowed(hint) + } +} diff --git a/src/table_renderer.rs b/src/table_renderer.rs index 95a7aec..4c30041 100644 --- a/src/table_renderer.rs +++ b/src/table_renderer.rs @@ -95,9 +95,7 @@ pub fn render_table(columns: &[ResultColumn], rows: &[Vec], max_value_len table.set_content_arrangement(ContentArrangement::Dynamic); // Detect terminal width and calculate equal column widths - let terminal_width = terminal_size() - .map(|(Width(w), _)| w) - .unwrap_or(80); + let terminal_width = terminal_size().map(|(Width(w), _)| w).unwrap_or(80); table.set_width(terminal_width); @@ -197,12 +195,7 @@ fn format_value(value: &Value) -> String { /// Calculate the display width of a string, ignoring ANSI escape codes /// Render table in vertical format (two-column table with column names and values) /// Used when table is too wide for horizontal display in auto mode -pub fn render_table_vertical( - columns: &[ResultColumn], - rows: &[Vec], - terminal_width: u16, - max_value_length: usize, -) -> String { +pub fn render_table_vertical(columns: &[ResultColumn], rows: &[Vec], terminal_width: u16, max_value_length: usize) -> String { let mut output = String::new(); for (row_idx, row) in rows.iter().enumerate() { @@ -218,8 +211,8 @@ pub fn render_table_vertical( // Second column (values): wide, allows wrapping let available_width = if terminal_width > 10 { terminal_width - 4 } else { 76 }; table.set_constraints(vec![ - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names - ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(30)), // Column names + ColumnConstraint::UpperBoundary(ComfyWidth::Fixed(available_width.saturating_sub(30))), // Values ]); // Add rows (no header - just column name | value pairs) @@ -235,9 +228,7 @@ pub fn render_table_vertical( }; // Column name cell (cyan, bold) - let name_cell = Cell::new(&col.name) - .fg(Color::Cyan) - .add_attribute(Attribute::Bold); + let name_cell = Cell::new(&col.name).fg(Color::Cyan).add_attribute(Attribute::Bold); // Value cell - color NULL values differently to distinguish from string "NULL" let value_cell = if row[col_idx].is_null() { @@ -308,11 +299,7 @@ pub fn write_result_as_csv( rows: &[Vec], ) -> Result<(), Box> { // Write CSV header - let header = columns - .iter() - .map(|col| escape_csv_field(&col.name)) - .collect::>() - .join(","); + let header = columns.iter().map(|col| escape_csv_field(&col.name)).collect::>().join(","); writeln!(writer, "{}", header)?; // Write data rows @@ -592,12 +579,10 @@ mod tests { #[test] fn test_render_vertical_value_truncation() { - let columns = vec![ - ResultColumn { - name: "long_col".to_string(), - column_type: "text".to_string(), - }, - ]; + let columns = vec![ResultColumn { + name: "long_col".to_string(), + column_type: "text".to_string(), + }]; let long_value = "a".repeat(2000); // 2000 characters let rows = vec![vec![Value::String(long_value)]]; @@ -785,7 +770,7 @@ mod tests { }, ]; let rows = vec![ - vec![Value::Number(1.into()), Value::Null], // Real NULL + vec![Value::Number(1.into()), Value::Null], // Real NULL vec![Value::Number(2.into()), Value::String("NULL".to_string())], // String "NULL" vec![Value::Number(3.into()), Value::String("test".to_string())], // Regular string ]; diff --git a/src/viewer.rs b/src/viewer.rs index 83f95ee..73d2d88 100644 --- a/src/viewer.rs +++ b/src/viewer.rs @@ -165,7 +165,12 @@ mod tests { // verify the file creation part let csv_path = temp_csv_path().unwrap(); let mut file = File::create(&csv_path).unwrap(); - write_result_as_csv(&mut file, &context.last_result.as_ref().unwrap().columns, &context.last_result.as_ref().unwrap().rows).unwrap(); + write_result_as_csv( + &mut file, + &context.last_result.as_ref().unwrap().columns, + &context.last_result.as_ref().unwrap().rows, + ) + .unwrap(); drop(file); // Verify file exists and has content diff --git a/tests/cli.rs b/tests/cli.rs index 74efbaa..e6db6d8 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,6 +1,6 @@ +use serde_json; use std::io::Write; use std::process::Command; -use serde_json; fn run_fb(args: &[&str]) -> (bool, String, String) { let output = Command::new(env!("CARGO_BIN_EXE_fb")) @@ -181,12 +181,7 @@ fn test_command_parsing() { #[test] fn test_exiting() { let mut child = Command::new(env!("CARGO_BIN_EXE_fb")) - .args(&[ - "--core", - "--concise", - "-f", - "TabSeparatedWithNamesAndTypes", - ]) + .args(&["--core", "--concise", "-f", "TabSeparatedWithNamesAndTypes"]) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .spawn()