diff --git a/Cargo.lock b/Cargo.lock index f4c6612..81ce186 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,6 +276,7 @@ dependencies = [ "ratatui", "rusqlite", "shellexpand", + "sqlparser", "tempfile", ] @@ -1314,6 +1315,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "sqlparser" +version = "0.63.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3679862809bd1f92e563cf6fd820e28f39135303668001c10331d5027183e8b4" +dependencies = [ + "log", +] + [[package]] name = "static_assertions" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8739649..d92245e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ postgres-native-tls = "0.5" native-tls = "0.2" rusqlite = { version = "0.40", default-features = false, features = ["bundled"] } shellexpand = { version = "3.1", features = ["path"] } +sqlparser = { version = "0.63", default-features = false, features = ["std"] } [dev-dependencies] tempfile = "3" diff --git a/TODO.md b/TODO.md index e69de29..1b8c858 100644 --- a/TODO.md +++ b/TODO.md @@ -0,0 +1,7 @@ +- [ ] pressing `i` on a configured database connection should bring up a floating window with details/info on that connection. +- [ ] show a `*` to the right of the connected db in the sidebar. +- [ ] use the proper ending character in the family of `├` instead of that character all the way down. +- [ ] remove this top-most line above the panels on the right-hand side: `dbdash │ SQLite │ READ ONLY`. +- [ ] highlight the current cell isntead of just the current row in the results pane. +- [ ] put the help text inside it's own pane so it fits the design more cleanly. +- [ ] adopt the vamp top and bottom bars for modal and other info display. \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index acd3369..fad562d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,7 +11,7 @@ use crate::{ Result, connection::Input, db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation}, - error_text, + error_text, sql, }; #[derive(Clone, Copy, PartialEq, Eq)] @@ -33,6 +33,7 @@ pub struct App { column_offset: usize, show_schema: bool, query: Input, + query_highlights: sql::Highlights, query_result: Option, error: Option, } @@ -56,6 +57,7 @@ impl App { column_offset: 0, show_schema: false, query: Input::default(), + query_highlights: Vec::new(), query_result: None, error: None, }; @@ -104,16 +106,27 @@ impl App { } pub(crate) fn handle_query_key(&mut self, key: KeyEvent) { + let previous_len = self.query.value.len(); match key.code { KeyCode::F(5) => self.execute_query(), KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(), KeyCode::Enter => self.query.paste_multiline("\n"), _ => self.query.edit(key), } + self.highlight_query(previous_len); } pub(crate) fn paste_query(&mut self, text: &str) { + let previous_len = self.query.value.len(); self.query.paste_multiline(text); + self.highlight_query(previous_len); + } + + fn highlight_query(&mut self, previous_len: usize) { + // Editing inserts or removes bytes; cursor movement does not invalidate syntax. + if self.query.value.len() != previous_len { + self.query_highlights = sql::highlight(&self.query.value, self.db.name() == "SQLite"); + } } fn execute_query(&mut self) { @@ -232,6 +245,7 @@ impl App { " Query · F5 execute ", false, focus == Focus::Query, + Some(sql::text(&self.query.value, &self.query_highlights)), ); let title = if self.query_result.is_some() { " Results " @@ -432,6 +446,36 @@ mod tests { assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2)); } + #[test] + fn query_colors_survive_editing_and_scrolling() -> crate::Result<()> { + use super::{App, Focus}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend, style::Color}; + let file = tempfile::NamedTempFile::new()?; + let mut app = App::new(crate::db::Database::open_sqlite(file.path())?)?; + let mut terminal = Terminal::new(TestBackend::new(60, 14))?; + app.paste_query("SELECT '界', 42;\n-- comment"); + terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; + let buffer = terminal.backend().buffer(); + assert_eq!(buffer[(1, 2)].fg, Color::Cyan); + assert_eq!(buffer[(8, 2)].fg, Color::Green); + assert_eq!(buffer[(1, 3)].fg, Color::DarkGray); + app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)); + app.paste_query("unfinished\nstring"); + terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; + assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Green); + app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90))); + terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; + let cursor = terminal.get_cursor_position()?; + let buffer = terminal.backend().buffer(); + assert_eq!(cursor.x, 58); + assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x"); + assert_eq!(buffer[(1, cursor.y)].fg, Color::DarkGray); + Ok(()) + } + #[test] fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; diff --git a/src/connection.rs b/src/connection.rs index 4621cf2..585fd8a 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -1,7 +1,4 @@ -use std::{ - borrow::Cow, - path::{Path, PathBuf}, -}; +use std::path::{Path, PathBuf}; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use postgres::{Config, config::SslMode}; @@ -9,7 +6,7 @@ use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout, Rect}, style::{Color, Style}, - text::Line, + text::{Line, Text}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; @@ -550,8 +547,14 @@ impl Form { if self.provider == 1 { let [path, description] = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details); - self.path - .draw(frame, path, " Database file path ", false, self.focus == 1); + self.path.draw( + frame, + path, + " Database file path ", + false, + self.focus == 1, + None, + ); frame.render_widget(Paragraph::new("Save an existing SQLite file path.\nFiles are never created or modified.\n\nSelect the saved database to connect.").wrap(Wrap { trim: false }), description); } else { let rows = Layout::vertical([ @@ -584,6 +587,7 @@ impl Form { labels[index], index == 4, self.focus == index + 1 && (!self.reconnect || index == 4), + None, ); } frame.render_widget( @@ -753,11 +757,12 @@ impl Input { label: &str, secret: bool, focused: bool, + highlighted: Option>, ) { - let text: Cow<'_, str> = if secret { - Cow::Owned("•".repeat(self.value.chars().count())) + let text = if secret { + Text::raw("•".repeat(self.value.chars().count())) } else { - Cow::Borrowed(&self.value) + highlighted.unwrap_or_else(|| Text::raw(&self.value)) }; let prefix = &self.value[..self.cursor]; let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX); diff --git a/src/main.rs b/src/main.rs index 586870e..0e28e4f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod connection; mod db; +mod sql; use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode}; @@ -45,6 +46,8 @@ Navigation: appears in the rows/schema border. Read errors appear above the query editor. In the query editor, type SQL directly; Enter inserts a newline. Arrow keys, Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported. + SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect, + including quoted identifiers, literals, and multiline comments/strings as you type. F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more. Failed queries keep the previous results. Opening a relation restores browsing diff --git a/src/sql.rs b/src/sql.rs new file mode 100644 index 0000000..5f48eac --- /dev/null +++ b/src/sql.rs @@ -0,0 +1,204 @@ +use ratatui::{ + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, +}; +use sqlparser::{ + dialect::{Dialect, PostgreSqlDialect, SQLiteDialect}, + keywords::Keyword, + tokenizer::{Location, Token, Tokenizer, Whitespace}, +}; + +// Contiguous token end offsets into the original UTF-8 query, not reconstructed SQL. +pub(crate) type Highlights = Vec<(usize, Style)>; + +pub(crate) fn highlight(source: &str, sqlite: bool) -> Highlights { + let dialect: &dyn Dialect = if sqlite { + &SQLiteDialect {} + } else { + &PostgreSqlDialect {} + }; + let mut tokens = Vec::new(); + // An unfinished token is normal while typing; completed tokens remain available. + let _ = Tokenizer::new(dialect, source) + .with_unescape(false) + .tokenize_with_location_into_buf(&mut tokens); + let mut chars = source.char_indices(); + let mut location = Location::new(1, 1); + let mut end = 0; + let mut highlights = Vec::with_capacity(tokens.len() + 1); + for token in tokens { + while location < token.span.end { + let Some((index, character)) = chars.next() else { + break; + }; + end = index + character.len_utf8(); + if character == '\n' { + location.line += 1; + location.column = 1; + } else { + location.column += 1; + } + } + let style = match token.token { + Token::Word(word) if word.quote_style.is_some() => Style::new().fg(Color::Yellow), + Token::Word(word) if word.keyword != Keyword::NoKeyword => { + Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD) + } + Token::Number(..) => Style::new().fg(Color::Magenta), + Token::SingleQuotedString(_) + | Token::DoubleQuotedString(_) + | Token::DollarQuotedString(_) + | Token::EscapedStringLiteral(_) + | Token::UnicodeStringLiteral(_) + | Token::NationalStringLiteral(_) + | Token::HexStringLiteral(_) + | Token::SingleQuotedByteStringLiteral(_) => Style::new().fg(Color::Green), + Token::Whitespace( + Whitespace::SingleLineComment { .. } | Whitespace::MultiLineComment(_), + ) => Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + Token::Placeholder(_) => Style::new().fg(Color::Yellow), + _ => Style::default(), + }; + highlights.push((end, style)); + } + if end < source.len() { + let tail = &source[end..]; + let style = if tail.starts_with("/*") { + Style::new() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC) + } else if tail.starts_with(['"', '`', '[']) { + Style::new().fg(Color::Yellow) + } else { + Style::new().fg(Color::Green) + }; + highlights.push((source.len(), style)); + } + highlights +} + +pub(crate) fn text<'a>(source: &'a str, highlights: &Highlights) -> Text<'a> { + let mut lines = vec![Line::default()]; + let mut start = 0; + for &(end, style) in highlights { + for (index, part) in source[start..end].split('\n').enumerate() { + if index > 0 { + lines.push(Line::default()); + } + if !part.is_empty() { + lines + .last_mut() + .unwrap() + .spans + .push(Span::styled(part, style)); + } + } + start = end; + } + Text::from(lines) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn colored(source: &str, sqlite: bool) -> Vec<(String, Option)> { + let highlights = highlight(source, sqlite); + let mut start = 0; + highlights + .into_iter() + .map(|(end, style)| { + let part = (source[start..end].to_owned(), style.fg); + start = end; + part + }) + .collect() + } + + #[test] + fn sql_context_controls_colors_without_changing_source() { + for sqlite in [false, true] { + let source = "sElEcT \"select\", 'it''s -- not a comment', 1.2e-3, 界é FROM notes -- SELECT 99\n/* 'FROM'\n*/ WHERE id >= 2;\n"; + let spans = colored(source, sqlite); + assert!(spans.contains(&("sElEcT".into(), Some(Color::Cyan)))); + assert!(spans.contains(&("\"select\"".into(), Some(Color::Yellow)))); + assert!(spans.contains(&("'it''s -- not a comment'".into(), Some(Color::Green)))); + assert!(spans.contains(&("1.2e-3".into(), Some(Color::Magenta)))); + assert!(spans.contains(&("界é".into(), None))); + assert!( + spans + .iter() + .any(|(part, color)| part.starts_with("-- SELECT") + && *color == Some(Color::DarkGray)) + ); + assert!(spans.contains(&("/* 'FROM'\n*/".into(), Some(Color::DarkGray)))); + let rendered = text(source, &highlight(source, sqlite)); + let visible = rendered + .lines + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + assert_eq!(visible, source); + } + } + + #[test] + fn dialects_and_unfinished_tokens_remain_highlighted() { + for (source, sqlite, fragment, color) in [ + ( + "SELECT E'it\\'s', $tag$-- SELECT\n'word'$tag$", + false, + "$tag$-- SELECT\n'word'$tag$", + Color::Green, + ), + ( + "SELECT /* outer /* nested */ still comment */ 1", + false, + "/* outer /* nested */ still comment */", + Color::DarkGray, + ), + ( + "SELECT [select], `from`, X'00FF', $param", + true, + "[select]", + Color::Yellow, + ), + ("SELECT '界\nFROM", false, "'界\nFROM", Color::Green), + ( + "SELECT $tag$unfinished\nFROM", + false, + "$tag$unfinished\nFROM", + Color::Green, + ), + ( + "SELECT /* unfinished\nWHERE", + false, + "/* unfinished\nWHERE", + Color::DarkGray, + ), + ( + "SELECT \"unfinished\nFROM", + false, + "\"unfinished\nFROM", + Color::Yellow, + ), + ("SELECT [unfinished", true, "[unfinished", Color::Yellow), + ] { + let spans = colored(source, sqlite); + assert!( + spans.contains(&(fragment.into(), Some(color))), + "{source}: {spans:?}" + ); + assert_eq!( + spans + .iter() + .map(|(part, _)| part.as_str()) + .collect::(), + source + ); + } + } +}