feat: add SQL syntax highlighting in query editor
This commit is contained in:
Generated
+10
@@ -276,6 +276,7 @@ dependencies = [
|
|||||||
"ratatui",
|
"ratatui",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"shellexpand",
|
"shellexpand",
|
||||||
|
"sqlparser",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1314,6 +1315,15 @@ dependencies = [
|
|||||||
"windows-sys",
|
"windows-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlparser"
|
||||||
|
version = "0.63.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3679862809bd1f92e563cf6fd820e28f39135303668001c10331d5027183e8b4"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "static_assertions"
|
name = "static_assertions"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ postgres-native-tls = "0.5"
|
|||||||
native-tls = "0.2"
|
native-tls = "0.2"
|
||||||
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
|
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
|
||||||
shellexpand = { version = "3.1", features = ["path"] }
|
shellexpand = { version = "3.1", features = ["path"] }
|
||||||
|
sqlparser = { version = "0.63", default-features = false, features = ["std"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -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.
|
||||||
+45
-1
@@ -11,7 +11,7 @@ use crate::{
|
|||||||
Result,
|
Result,
|
||||||
connection::Input,
|
connection::Input,
|
||||||
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
|
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
|
||||||
error_text,
|
error_text, sql,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -33,6 +33,7 @@ pub struct App {
|
|||||||
column_offset: usize,
|
column_offset: usize,
|
||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
query: Input,
|
query: Input,
|
||||||
|
query_highlights: sql::Highlights,
|
||||||
query_result: Option<QueryResult>,
|
query_result: Option<QueryResult>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@ impl App {
|
|||||||
column_offset: 0,
|
column_offset: 0,
|
||||||
show_schema: false,
|
show_schema: false,
|
||||||
query: Input::default(),
|
query: Input::default(),
|
||||||
|
query_highlights: Vec::new(),
|
||||||
query_result: None,
|
query_result: None,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
@@ -104,16 +106,27 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
|
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
|
||||||
|
let previous_len = self.query.value.len();
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::F(5) => self.execute_query(),
|
KeyCode::F(5) => self.execute_query(),
|
||||||
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
||||||
KeyCode::Enter => self.query.paste_multiline("\n"),
|
KeyCode::Enter => self.query.paste_multiline("\n"),
|
||||||
_ => self.query.edit(key),
|
_ => self.query.edit(key),
|
||||||
}
|
}
|
||||||
|
self.highlight_query(previous_len);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn paste_query(&mut self, text: &str) {
|
pub(crate) fn paste_query(&mut self, text: &str) {
|
||||||
|
let previous_len = self.query.value.len();
|
||||||
self.query.paste_multiline(text);
|
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) {
|
fn execute_query(&mut self) {
|
||||||
@@ -232,6 +245,7 @@ impl App {
|
|||||||
" Query · F5 execute ",
|
" Query · F5 execute ",
|
||||||
false,
|
false,
|
||||||
focus == Focus::Query,
|
focus == Focus::Query,
|
||||||
|
Some(sql::text(&self.query.value, &self.query_highlights)),
|
||||||
);
|
);
|
||||||
let title = if self.query_result.is_some() {
|
let title = if self.query_result.is_some() {
|
||||||
" Results "
|
" Results "
|
||||||
@@ -432,6 +446,36 @@ mod tests {
|
|||||||
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
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]
|
#[test]
|
||||||
fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> {
|
fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> {
|
||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|||||||
+15
-10
@@ -1,7 +1,4 @@
|
|||||||
use std::{
|
use std::path::{Path, PathBuf};
|
||||||
borrow::Cow,
|
|
||||||
path::{Path, PathBuf},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
use postgres::{Config, config::SslMode};
|
use postgres::{Config, config::SslMode};
|
||||||
@@ -9,7 +6,7 @@ use ratatui::{
|
|||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::Line,
|
text::{Line, Text},
|
||||||
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -550,8 +547,14 @@ impl Form {
|
|||||||
if self.provider == 1 {
|
if self.provider == 1 {
|
||||||
let [path, description] =
|
let [path, description] =
|
||||||
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details);
|
||||||
self.path
|
self.path.draw(
|
||||||
.draw(frame, path, " Database file path ", false, self.focus == 1);
|
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);
|
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 {
|
} else {
|
||||||
let rows = Layout::vertical([
|
let rows = Layout::vertical([
|
||||||
@@ -584,6 +587,7 @@ impl Form {
|
|||||||
labels[index],
|
labels[index],
|
||||||
index == 4,
|
index == 4,
|
||||||
self.focus == index + 1 && (!self.reconnect || index == 4),
|
self.focus == index + 1 && (!self.reconnect || index == 4),
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
@@ -753,11 +757,12 @@ impl Input {
|
|||||||
label: &str,
|
label: &str,
|
||||||
secret: bool,
|
secret: bool,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
|
highlighted: Option<Text<'_>>,
|
||||||
) {
|
) {
|
||||||
let text: Cow<'_, str> = if secret {
|
let text = if secret {
|
||||||
Cow::Owned("•".repeat(self.value.chars().count()))
|
Text::raw("•".repeat(self.value.chars().count()))
|
||||||
} else {
|
} else {
|
||||||
Cow::Borrowed(&self.value)
|
highlighted.unwrap_or_else(|| Text::raw(&self.value))
|
||||||
};
|
};
|
||||||
let prefix = &self.value[..self.cursor];
|
let prefix = &self.value[..self.cursor];
|
||||||
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
|
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod connection;
|
mod connection;
|
||||||
mod db;
|
mod db;
|
||||||
|
mod sql;
|
||||||
|
|
||||||
use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode};
|
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.
|
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,
|
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.
|
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
|
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.
|
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
|
||||||
Failed queries keep the previous results. Opening a relation restores browsing
|
Failed queries keep the previous results. Opening a relation restores browsing
|
||||||
|
|||||||
+204
@@ -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<Color>)> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.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::<String>(),
|
||||||
|
source
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user