use std::io::Write;
use inillucent_engine::connect::{Connection, Database};
use inillucent_tree::datum::{owned_row_values, OwnedDatum};
use inillucent_value::Value;
use crate::render::{render, Layout, Mode};
pub struct Opened {
database: Database,
session: u64,
path: String,
}
pub const CONNECTIONS: usize = 5;
pub struct Shell {
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
connections: Vec<Option<Opened>>,
active: usize,
pub layout: Layout,
output: Option<std::fs::File>,
output_name: Option<String>,
output_is_once: bool,
pub bail: bool,
pub echo: bool,
pub timer: bool,
pub stats: bool,
pub show_changes: bool,
pub explain_plan: bool,
pub crlf: bool,
pub prompt_main: String,
pub prompt_continue: String,
pub explain_mode: crate::commands::ExplainMode,
pub nonce: Option<String>,
pub testcase: Option<String>,
pub captured: String,
pub tests_run: usize,
pub tests_failed: usize,
pub viewer: Option<std::path::PathBuf>,
pub auth: bool,
pub authorized: std::rc::Rc<std::cell::RefCell<Vec<String>>>,
pub trace: Option<String>,
pub scanstats: String,
pub defensive: bool,
pub done: bool,
pub failed: bool,
pub first_error: Option<inillucent_base::DbError>,
pub line: usize,
pub log_to: Option<String>,
pub progress_interval: u64,
pub progress_limit: u64,
pub progress_once: bool,
pub progress_quiet: bool,
pub progress_pending_limit: bool,
pub readonly: bool,
pub safe: bool,
pub sink: Option<String>,
pub rows_since_redirect: usize,
pub parameters: std::collections::BTreeMap<String, Value<'static>>,
}
fn described(error: inillucent_base::DbError) -> String {
match error.detail() {
Some(detail) => format!("{}: {detail}", error.message()),
None => error.message().to_string(),
}
}
pub struct Failure {
pub message: String,
pub offset: Option<u32>,
pub compiling: bool,
pub error: Option<inillucent_base::DbError>,
}
impl Shell {
pub fn open_one(path: &str) -> Result<Opened, String> {
Shell::open_one_as(path, false)
}
pub fn open_one_as(path: &str, read_only: bool) -> Result<Opened, String> {
Shell::open_one_reporting(path, read_only).map_err(described)
}
pub fn open_one_reporting(
path: &str,
read_only: bool,
) -> Result<Opened, inillucent_base::DbError> {
let database = match read_only {
true => Database::open_read_only(path, inillucent_driver::DEFAULT_FRAMES),
false => Database::open(path),
}?;
for module in [
std::sync::Arc::new(inillucent_driver::vtab::fsdir::FsDirModule)
as std::sync::Arc<dyn inillucent_driver::vtab::Module>,
std::sync::Arc::new(inillucent_driver::vtab::zipfile::ZipFileModule),
] {
database.register_module(module)?;
}
let session = database.session().session();
let _ = database.session_as(session).set_defensive(true);
let _ = database
.session_as(session)
.execute_batch("PRAGMA trusted_schema = OFF;");
Ok(Opened {
database,
session,
path: path.to_string(),
})
}
pub fn open(path: &str) -> Result<Shell, String> {
Shell::open_as(path, false)
}
pub fn open_as(path: &str, read_only: bool) -> Result<Shell, String> {
Shell::open_reporting(path, read_only).map_err(described)
}
pub fn open_reporting(path: &str, read_only: bool) -> Result<Shell, inillucent_base::DbError> {
let mut connections: Vec<Option<Opened>> = (0..CONNECTIONS).map(|_| None).collect();
if let Some(first) = connections.first_mut() {
*first = Some(Shell::open_one_reporting(path, read_only)?);
}
Ok(Shell {
cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
connections,
active: 0,
layout: Layout::default(),
output: None,
output_name: None,
output_is_once: false,
bail: false,
echo: false,
timer: false,
stats: false,
show_changes: false,
explain_plan: false,
crlf: false,
prompt_main: "sqlite> ".to_string(),
prompt_continue: " ...> ".to_string(),
explain_mode: crate::commands::ExplainMode::Auto,
nonce: None,
testcase: None,
captured: String::new(),
tests_run: 0,
tests_failed: 0,
viewer: None,
auth: false,
authorized: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
trace: None,
scanstats: "off".to_string(),
defensive: true,
done: false,
failed: false,
first_error: None,
log_to: None,
progress_interval: 0,
progress_limit: 0,
progress_once: false,
progress_quiet: false,
progress_pending_limit: false,
parameters: std::collections::BTreeMap::new(),
readonly: false,
safe: false,
sink: None,
rows_since_redirect: 0,
line: 1,
})
}
pub fn connection(&self) -> Connection<'_> {
let held = self.open_slot();
held.database.session_as(held.session)
}
pub fn limit(&self, limit: inillucent_base::limits::Limit) -> i64 {
self.open_slot().database.limit(limit)
}
pub fn set_limit(&mut self, limit: inillucent_base::limits::Limit, requested: i64) -> i64 {
self.open_slot().database.set_limit(limit, requested)
}
pub fn boolean_pragma(&self, name: &str) -> bool {
self.column(&format!("PRAGMA {name};"))
.first()
.is_some_and(|value| value == "1")
}
pub fn set_boolean_pragma(&mut self, name: &str, value: bool) -> bool {
let word = if value { "on" } else { "off" };
self.collect(&format!("PRAGMA {name} = {word};")).is_ok()
&& self.boolean_pragma(name) == value
}
pub fn set_authorizer(&mut self, on: bool) {
let installed: Option<std::rc::Rc<dyn inillucent_driver::Authorizer>> = on.then(|| {
std::rc::Rc::new(crate::commands::Watching {
seen: std::rc::Rc::clone(&self.authorized),
}) as std::rc::Rc<dyn inillucent_driver::Authorizer>
});
let _ = self.connection().set_authorizer(installed);
}
fn report_authorized(&mut self) {
let lines: Vec<String> = self.authorized.borrow_mut().drain(..).collect();
for line in lines {
self.say(&line);
}
}
pub fn set_defensive(&mut self, on: bool) -> bool {
let _ = self.connection().set_defensive(on);
true
}
pub fn cache_stats(&self) -> inillucent_driver::CacheStats {
self.open_slot().database.cache_stats()
}
pub fn pool_bytes(&self) -> usize {
self.open_slot().database.pool_bytes()
}
pub fn backup_to(&self, path: &str) -> Result<(), String> {
self.open_slot()
.database
.backup_to(path)
.map_err(|error| error.message().to_string())
}
pub fn path(&self) -> &str {
&self.open_slot().path
}
#[allow(clippy::expect_used)]
fn open_slot(&self) -> &Opened {
self.connections
.get(self.active)
.and_then(|held| held.as_ref())
.or_else(|| self.connections.first().and_then(|held| held.as_ref()))
.expect("the shell always holds one open database")
}
pub fn recovery(&self) -> inillucent_driver::Recovery {
let report = self.open_slot().database.recovery_report();
inillucent_driver::Recovery {
recovered: report.recovered,
scanned: report.scanned,
applied: report.applied,
dropped: report.dropped,
committed: report.committed,
losers: report.losers,
last_sequence: report.last_sequence,
last_lsn: report.last_lsn,
}
}
pub fn log_sequence(&self) -> u64 {
self.open_slot().database.log_sequence()
}
pub fn active(&self) -> usize {
self.active
}
pub fn slots(&self) -> Vec<Option<String>> {
self.connections
.iter()
.map(|held| held.as_ref().map(|open| open.path.clone()))
.collect()
}
pub fn use_slot(&mut self, slot: usize) -> Result<(), String> {
if slot >= CONNECTIONS {
return Ok(());
}
if self.connections.get(slot).is_some_and(Option::is_none) {
let opened = Shell::open_one(":memory:")?;
if let Some(place) = self.connections.get_mut(slot) {
*place = Some(opened);
}
}
self.active = slot;
Ok(())
}
pub fn close_slot(&mut self, slot: usize) {
if slot == 0 || slot >= CONNECTIONS {
return;
}
if let Some(place) = self.connections.get_mut(slot) {
*place = None;
}
if self.active == slot {
self.active = 0;
}
}
pub fn reopen(&mut self, path: &str) -> Result<(), String> {
let replacement = Shell::open_one(path)?;
let active = self.active;
if let Some(place) = self.connections.get_mut(active) {
*place = Some(replacement);
}
Ok(())
}
fn rendering_layout(&self) -> crate::render::Layout {
let mut layout = self.layout.clone();
layout.to_stdout = self.output.is_none() && self.sink.is_none() && self.testcase.is_none();
layout
}
pub fn say(&mut self, line: &str) {
if self.testcase.is_some() {
self.captured.push_str(line);
self.captured.push('\n');
return;
}
let ending = if self.crlf { "\r\n" } else { "\n" };
if let Some(file) = self.output.as_mut() {
let _ = write!(file, "{line}{ending}");
return;
}
if let Some(sink) = self.sink.as_mut() {
sink.push_str(line);
sink.push('\n');
return;
}
let mut out = std::io::stdout();
let _ = write!(out, "{line}{ending}");
}
pub fn complain(&mut self, message: &str) {
match self.sink.as_mut() {
Some(sink) => {
sink.push_str(message);
sink.push('\n');
}
None => eprintln!("{message}"),
}
self.failed = true;
}
pub fn unsafe_refused(&mut self, command: &str) -> bool {
if !self.safe {
return false;
}
self.complain(&format!("Error: {command} is prohibited in safe mode"));
true
}
pub fn redirect(&mut self, path: Option<&str>, once: bool) -> Result<(), String> {
self.rows_since_redirect = 0;
let Some(path) = path else {
self.output = None;
self.output_name = None;
self.output_is_once = false;
return Ok(());
};
let file = std::fs::File::create(path).map_err(|error| error.to_string())?;
self.output = Some(file);
self.output_name = Some(path.to_string());
self.output_is_once = once;
Ok(())
}
pub fn output_target(&self) -> &str {
self.output_name.as_deref().unwrap_or("stdout")
}
fn finish_once(&mut self) {
if self.output_is_once {
self.output = None;
self.output_name = None;
self.output_is_once = false;
}
if let Some(path) = self.viewer.take() {
crate::commands::open_viewer(&path);
}
}
pub fn cancel_flag(&self) -> std::sync::Arc<std::sync::atomic::AtomicBool> {
std::sync::Arc::clone(&self.cancel)
}
pub fn run(&mut self, sql: &str) {
if self.readonly && self.writes(sql) {
self.complain("Error: attempt to write a readonly database");
return;
}
if self.echo {
let text = sql.to_string();
self.say(&text);
}
let started = std::time::Instant::now();
if self.explain_plan {
self.print_plan(sql);
}
let armed = inillucent_driver::arm(
inillucent_driver::StatementLimits::unbounded(),
std::sync::Arc::clone(&self.cancel),
);
let outcome = self.collect(sql);
drop(armed);
if self.auth {
self.report_authorized();
}
match outcome {
Err(failure) => {
self.report(sql, &failure);
}
Ok((columns, rows)) => {
self.rows_since_redirect = self.rows_since_redirect.saturating_add(rows.len());
if is_query_plan(sql) {
for line in plan_tree(&rows) {
self.say(&line);
}
self.finish_once();
return;
}
let as_table = match self.explain_mode {
crate::commands::ExplainMode::Auto => is_bytecode_explain(sql),
crate::commands::ExplainMode::On => true,
crate::commands::ExplainMode::Off => false,
};
if as_table && columns.len() == EXPLAIN_WIDTHS.len() {
for line in explain_table(&columns, &rows) {
self.say(&line);
}
self.finish_once();
return;
}
let layout = self.rendering_layout();
for line in render(&layout, &columns, &rows) {
self.say(&line);
}
if self.show_changes {
let changes = self.connection().changes().unwrap_or_default();
let total = self.connection().total_changes().unwrap_or_default();
self.say(&format!("changes: {changes} total_changes: {total}"));
}
}
}
if self.stats {
for line in crate::diagnose::statistics(self) {
self.say(&line);
}
}
if self.timer {
let elapsed = started.elapsed();
self.say(&format!("Run Time: real {:.3}", elapsed.as_secs_f64()));
}
self.finish_once();
}
fn report(&mut self, sql: &str, failure: &Failure) {
if self.first_error.is_none() {
self.first_error = failure.error.clone();
}
let line = self.line;
let heading = if failure.compiling {
format!("Parse error near line {line}: {}", failure.message)
} else {
format!("Error near line {line}: {}", failure.message)
};
self.complain(&heading);
let Some(offset) = failure.offset else {
return;
};
for line in error_context(sql.as_bytes(), offset as usize) {
self.complain(&line);
}
}
pub fn collect(&self, sql: &str) -> Result<(Vec<String>, Vec<Vec<Value<'static>>>), Failure> {
self.collect_bound(sql, &[])
}
pub fn collect_bound(
&self,
sql: &str,
bound: &[OwnedDatum],
) -> Result<(Vec<String>, Vec<Vec<Value<'static>>>), Failure> {
let connection = self.connection();
let mut statement = connection.prepare(sql).map_err(|error| Failure {
message: reason(&error),
offset: error.sql_offset(),
compiling: true,
error: Some(error),
})?;
for (nth, value) in bound.iter().enumerate() {
statement
.bind(nth as u32 + 1, value.clone())
.map_err(|error| Failure {
message: reason(&error),
offset: None,
compiling: true,
error: Some(error),
})?;
}
if !self.parameters.is_empty() {
let names = connection.parameter_names(sql).unwrap_or_default();
for (name, index) in names {
let key = String::from_utf8_lossy(&name).into_owned();
let Some(value) = self.parameters.get(&key) else {
continue;
};
let _ = statement.bind(index, OwnedDatum::from(value));
}
}
let mut rows = Vec::new();
loop {
match statement.step() {
Err(error) => {
return Err(Failure {
message: reason(&error),
offset: None,
compiling: false,
error: Some(error),
})
}
Ok(false) => break,
Ok(true) => match owned_row_values(statement.row()) {
Ok(row) => rows.push(row),
Err(error) => {
return Err(Failure {
message: reason(&error),
offset: None,
compiling: false,
error: Some(error),
})
}
},
}
}
let columns: Vec<String> = statement.columns().to_vec();
Ok((columns, rows))
}
fn print_plan(&mut self, sql: &str) {
let plan = format!("EXPLAIN QUERY PLAN {sql}");
let Ok((_, rows)) = self.collect(&plan) else {
return;
};
for line in plan_tree(&rows) {
self.say(&line);
}
}
pub fn trailing_statement(&self, sql: &str) -> Option<String> {
let connection = self.connection();
let consumed = connection.prepare_with_tail(sql).ok()?.consumed;
let left = sql.get(consumed..)?;
let rest = left.get(inillucent_driver::leading_trivia(left)..)?.trim();
if rest.is_empty() {
return None;
}
Some(rest.chars().take(60).collect())
}
pub fn execute(&mut self, sql: &str) -> Result<(), String> {
self.connection()
.execute_batch(sql)
.map_err(|error| reason(&error))
}
pub fn writes(&self, sql: &str) -> bool {
!inillucent_driver::readonly::admits(sql)
}
pub fn scalar(&self, sql: &str) -> Option<String> {
let (_, rows) = self.collect(sql).ok()?;
let value = rows.first().and_then(|row| row.first())?;
Some(match value {
Value::Null => String::new(),
Value::Text(text) => String::from_utf8_lossy(text.raw()).into_owned(),
other => crate::render::literal(other),
})
}
pub fn column(&self, sql: &str) -> Vec<String> {
let Ok((_, rows)) = self.collect(sql) else {
return Vec::new();
};
rows.iter()
.filter_map(|row| row.first())
.map(|value| match value {
Value::Null => String::new(),
Value::Text(text) => String::from_utf8_lossy(text.raw()).into_owned(),
other => crate::render::literal(other),
})
.collect()
}
}
pub fn drive(shell: &mut Shell, input: impl Iterator<Item = String>) {
let mut pending = String::new();
let mut number = 0usize;
let mut started = 1usize;
for line in input {
number += 1;
if pending.trim().is_empty() {
started = number;
}
shell.line = started;
if pending.trim().is_empty() && line.trim_start().starts_with('.') {
if shell.echo {
let text = line.trim().to_string();
shell.say(&text);
}
crate::dot::run(shell, line.trim());
if shell.done || (shell.failed && shell.bail) {
return;
}
continue;
}
pending.push_str(&line);
pending.push('\n');
while let Some(consumed) = complete_statement(shell, &pending) {
let statement = pending.get(..consumed).unwrap_or_default().to_string();
let rest = pending.split_off(consumed);
pending = rest;
if !statement.trim().is_empty() {
shell.run(statement.trim());
if shell.done || (shell.failed && shell.bail) {
return;
}
}
}
}
if !pending.trim().is_empty() {
let statement = pending.trim().to_string();
shell.run(&statement);
}
}
fn complete_statement(_shell: &Shell, text: &str) -> Option<usize> {
let mut state = State::Start;
let bytes = text.as_bytes();
let mut at = 0usize;
while at < bytes.len() {
let Some(byte) = bytes.get(at).copied() else {
break;
};
match byte {
b'-' if bytes.get(at + 1) == Some(&b'-') => {
at = skip_line_comment(bytes, at);
}
b'/' if bytes.get(at + 1) == Some(&b'*') => {
at = skip_block_comment(bytes, at)?;
}
b'\'' | b'"' | b'`' => {
at = skip_quoted(bytes, at, byte)?;
}
b'[' => {
at = skip_quoted(bytes, at, b']')?;
}
b';' => {
at += 1;
if state.ends_here() {
return Some(at);
}
state = state.after_semicolon();
}
_ if byte.is_ascii_alphabetic() || byte == b'_' => {
let end = word_end(bytes, at);
let word = bytes.get(at..end).unwrap_or(&[]).to_ascii_uppercase();
state = state.after_word(&word);
at = end;
}
_ if byte.is_ascii_whitespace() => at += 1,
_ => {
state = state.after_other();
at += 1;
}
}
}
None
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum State {
Start,
Plain,
Create,
Trigger,
Body,
Semi,
End,
}
impl State {
fn ends_here(self) -> bool {
!matches!(self, State::Trigger | State::Body | State::Semi)
}
fn after_semicolon(self) -> State {
match self {
State::Trigger | State::Body | State::Semi => State::Semi,
_ => State::Start,
}
}
fn after_word(self, word: &[u8]) -> State {
match self {
State::Start if word == b"CREATE" => State::Create,
State::Start if word == b"EXPLAIN" => State::Start,
State::Start => State::Plain,
State::Create
if matches!(
word,
b"TEMP" | b"TEMPORARY" | b"IF" | b"NOT" | b"EXISTS" | b"OR" | b"REPLACE"
) =>
{
State::Create
}
State::Create if word == b"TRIGGER" => State::Trigger,
State::Create => State::Plain,
State::Trigger if word == b"BEGIN" => State::Body,
State::Semi if word == b"END" => State::End,
State::Semi | State::End => State::Body,
other => other,
}
}
fn after_other(self) -> State {
match self {
State::Start => State::Plain,
State::Semi | State::End => State::Body,
other => other,
}
}
}
fn skip_line_comment(bytes: &[u8], at: usize) -> usize {
let mut scan = at + 2;
while scan < bytes.len() {
if bytes.get(scan) == Some(&b'\n') {
return scan + 1;
}
scan += 1;
}
scan
}
fn skip_block_comment(bytes: &[u8], at: usize) -> Option<usize> {
let mut scan = at + 2;
while scan + 1 < bytes.len() {
if bytes.get(scan) == Some(&b'*') && bytes.get(scan + 1) == Some(&b'/') {
return Some(scan + 2);
}
scan += 1;
}
None
}
fn skip_quoted(bytes: &[u8], at: usize, close: u8) -> Option<usize> {
let open = bytes.get(at).copied()?;
let mut scan = at + 1;
while scan < bytes.len() {
let byte = bytes.get(scan).copied()?;
if byte == close {
if close == open && bytes.get(scan + 1) == Some(&close) {
scan += 2;
continue;
}
return Some(scan + 1);
}
scan += 1;
}
None
}
fn word_end(bytes: &[u8], at: usize) -> usize {
let mut scan = at;
while scan < bytes.len() {
match bytes.get(scan) {
Some(byte) if byte.is_ascii_alphanumeric() || *byte == b'_' => scan += 1,
_ => break,
}
}
scan
}
pub fn mode_named(name: &str) -> Result<Mode, String> {
Mode::from_name(name).ok_or_else(|| format!("Error: mode should be one of: {}", MODE_NAMES))
}
pub const MODE_NAMES: &str = "box column csv html insert json line list markdown quote table tabs";
fn is_query_plan(sql: &str) -> bool {
let mut words = sql.split_whitespace();
words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("explain"))
&& words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("query"))
&& words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("plan"))
}
const EXPLAIN_WIDTHS: [usize; 8] = [4, 13, 4, 4, 4, 13, 2, 13];
fn is_bytecode_explain(sql: &str) -> bool {
let mut words = sql.split_whitespace();
words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("explain"))
&& !words
.next()
.is_some_and(|word| word.eq_ignore_ascii_case("query"))
}
fn explain_table(columns: &[String], rows: &[Vec<Value<'static>>]) -> Vec<String> {
const GAP: &str = " ";
let mut lines = Vec::with_capacity(rows.len().saturating_add(2));
lines.push(
columns
.iter()
.enumerate()
.map(|(at, name)| pad(name, EXPLAIN_WIDTHS.get(at).copied().unwrap_or(0)))
.collect::<Vec<String>>()
.join(GAP),
);
lines.push(
EXPLAIN_WIDTHS
.iter()
.map(|width| "-".repeat(*width))
.collect::<Vec<String>>()
.join(GAP),
);
let last = EXPLAIN_WIDTHS.len().saturating_sub(1);
for row in rows {
let cells: Vec<String> = (0..EXPLAIN_WIDTHS.len())
.map(|at| {
let text = match row.get(at) {
Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
Some(Value::Null) | None => String::new(),
Some(other) => crate::render::literal(other),
};
if at == last {
text
} else {
pad(&text, EXPLAIN_WIDTHS.get(at).copied().unwrap_or(0))
}
})
.collect();
lines.push(cells.join(GAP));
}
lines
}
fn pad(text: &str, width: usize) -> String {
let mut out = text.to_string();
while out.chars().count() < width {
out.push(' ');
}
out
}
pub fn plan_tree(rows: &[Vec<Value<'static>>]) -> Vec<String> {
if rows.is_empty() {
return Vec::new();
}
let mut lines = vec!["QUERY PLAN".to_string()];
plan_children(rows, 0, "", 0, &mut lines);
lines
}
const LAST_ARM: &str = "`--";
const PLAN_DEPTH: usize = 64;
fn plan_children(
rows: &[Vec<Value<'static>>],
parent: i64,
prefix: &str,
depth: usize,
lines: &mut Vec<String>,
) {
if depth >= PLAN_DEPTH {
return;
}
let field = |row: &Vec<Value<'static>>, at: usize| -> i64 {
row.get(at).and_then(Value::as_integer).unwrap_or(0)
};
let children: Vec<&Vec<Value<'static>>> =
rows.iter().filter(|row| field(row, 1) == parent).collect();
for (at, row) in children.iter().enumerate() {
let last = at.saturating_add(1) == children.len();
let detail = row
.last()
.and_then(Value::as_text)
.map(|text| String::from_utf8_lossy(text.raw()).into_owned())
.unwrap_or_default();
let arm = if last { LAST_ARM } else { "|--" };
lines.push(format!("{prefix}{arm}{detail}"));
let carried = format!("{prefix}{}", if last { " " } else { "| " });
if field(row, 0) != parent {
plan_children(
rows,
field(row, 0),
&carried,
depth.saturating_add(1),
lines,
);
}
}
}
fn error_context(sql: &[u8], offset: usize) -> Vec<String> {
if offset >= sql.len() {
return Vec::new();
}
let mut start = 0usize;
let mut column = offset;
while column > 50 {
start += 1;
column -= 1;
while sql.get(start).is_some_and(|byte| byte & 0xc0 == 0x80) {
start += 1;
column -= 1;
}
}
let window = sql.get(start..).unwrap_or_default();
let mut length = window.len().min(78);
while length > 0 && window.get(length).is_some_and(|byte| byte & 0xc0 == 0x80) {
length -= 1;
}
let shown = String::from_utf8_lossy(window.get(..length).unwrap_or_default())
.chars()
.map(|character| {
if character.is_ascii_whitespace() {
' '
} else {
character
}
})
.collect::<String>();
let marker = if column < 25 {
format!(" {}^--- error here", " ".repeat(column))
} else {
format!(" {}error here ---^", " ".repeat(column - 14))
};
vec![format!(" {shown}"), marker]
}
fn reason(error: &inillucent_base::DbError) -> String {
error
.detail()
.unwrap_or_else(|| error.message())
.to_string()
}
#[cfg(test)]
mod trailing_statement_tests {
use super::Shell;
fn shell() -> Shell {
Shell::open(":memory:").expect("a memory database opens")
}
#[test]
fn a_second_statement_is_recognised_and_punctuation_is_not() {
let held = shell();
for one in [
"CREATE TABLE a (id INTEGER PRIMARY KEY)",
"CREATE TABLE a (id INTEGER PRIMARY KEY);",
"CREATE TABLE a (id INTEGER PRIMARY KEY); ",
"CREATE TABLE a (id INTEGER PRIMARY KEY); -- and that is all",
"CREATE TABLE a (id INTEGER PRIMARY KEY); /* and that is all */",
"CREATE TABLE a (id INTEGER PRIMARY KEY);;;",
"CREATE TRIGGER t AFTER INSERT ON a FOR EACH ROW BEGIN UPDATE a SET id = id; END",
"CREATE TRIGGER t AFTER INSERT ON a BEGIN SELECT CASE WHEN NEW.id < 0 THEN RAISE(ABORT, 'no') END; END",
] {
assert_eq!(
held.trailing_statement(one),
None,
"{one:?} is one statement"
);
}
let two = held
.trailing_statement(
"CREATE TABLE a (id INTEGER PRIMARY KEY); CREATE TABLE b (id INTEGER PRIMARY KEY)",
)
.expect("two statements are two statements");
assert!(
two.starts_with("CREATE TABLE b"),
"the refusal names what comes next, and said {two:?}"
);
let commented = held
.trailing_statement(
"CREATE TABLE a (id INTEGER PRIMARY KEY); -- next
CREATE TABLE b (id INTEGER PRIMARY KEY)",
)
.expect("a comment does not hide a statement");
assert!(
commented.starts_with("CREATE TABLE b"),
"said {commented:?}"
);
}
#[test]
fn a_statement_ends_where_sqlite3_complete_says() {
let held = shell();
let guard = "CREATE TRIGGER g BEFORE UPDATE ON o BEGIN SELECT CASE WHEN NEW.n < 0 THEN RAISE(ABORT, 'negative ' || NEW.n) END; END;";
for (text, wanted) in [
("SELECT 1; SELECT 2;", Some(9)),
("CREATE TRIGGER t AFTER INSERT ON a BEGIN UPDATE a SET id = id; END; SELECT 1;", Some(67)),
("CREATE TRIGGER t AFTER INSERT ON a BEGIN UPDATE a SET id = id;", None),
(guard, Some(guard.len())),
("CREATE TRIGGER g BEFORE UPDATE ON o BEGIN SELECT CASE WHEN 1 THEN 2 END; SELECT 3; END;", Some(87)),
("CREATE TRIGGER g BEFORE UPDATE ON o BEGIN SELECT CASE WHEN 1 THEN 2 END;", None),
] {
assert_eq!(
super::complete_statement(&held, text),
wanted,
"how much of {text:?} is one statement"
);
}
}
#[test]
fn text_that_does_not_compile_is_left_to_the_parser() {
let held = shell();
assert_eq!(held.trailing_statement("SELEKT 1"), None);
assert_eq!(held.trailing_statement(""), None);
assert_eq!(held.trailing_statement("-- only a comment"), None);
}
}