use crate::render::{literal, Layout};
use crate::shell::{drive, mode_named, Shell};
use inillucent_value::Value;
pub(crate) fn confine_path(shell: &mut Shell, path: &str) -> Option<String> {
let Some(root) = inillucent_driver::vfs::confine::process_root() else {
return Some(path.to_string());
};
match root.admit(path) {
Ok(resolved) => Some(resolved.to_string_lossy().into_owned()),
Err(refused) => {
shell.complain(&format!("Error: {}", refused.message()));
None
}
}
}
const REFUSED_IN_SAFE_MODE: &[&str] = &[
"ar", "archive", "backup", "cd", "clone", "excel", "import", "load", "once", "output", "read",
"restore", "save", "shell", "system", "www",
];
fn refused_by_safe_mode(shell: &mut Shell, name: &str) -> bool {
if !REFUSED_IN_SAFE_MODE.contains(&name) {
return false;
}
shell.unsafe_refused(&format!(".{name}"))
}
pub fn run(shell: &mut Shell, line: &str) {
let words = split(without_terminator(line));
let Some(name) = words
.first()
.map(|word| word.trim_start_matches('.').to_string())
else {
return;
};
let arguments: Vec<&str> = words.iter().skip(1).map(String::as_str).collect();
if refused_by_safe_mode(shell, &name) {
return;
}
match name.as_str() {
"quit" | "exit" => shell.done = true,
"help" => help(shell, &arguments),
"open" => open(shell, &arguments),
"databases" => databases(shell),
"tables" => tables(shell, &arguments),
"indexes" | "indices" => indexes(shell, &arguments),
"schema" => schema(shell, &arguments),
"fullschema" => full_schema(shell),
"headers" => shell.layout.headers = truthy(arguments.first().copied()),
"mode" => mode(shell, &arguments),
"separator" => separator(shell, &arguments),
"nullvalue" => match arguments.first() {
Some(text) => shell.layout.null = resolve_backslashes(text),
None => shell.complain("Usage: .nullvalue STRING"),
},
"width" => width(shell, &arguments),
"output" => output(shell, &arguments, false),
"once" => output(shell, &arguments, true),
"print" => {
let text = arguments.join(" ");
shell.say(&text);
}
"echo" => shell.echo = truthy(arguments.first().copied()),
"bail" => shell.bail = truthy(arguments.first().copied()),
"timer" => shell.timer = truthy(arguments.first().copied()),
"archive" | "ar" => crate::archive::archive(shell, &arguments),
"stats" => crate::diagnose::stats(shell, &arguments),
"vfslist" => crate::diagnose::vfs_list(shell),
"vfsinfo" | "vfsname" => crate::diagnose::vfs_info(shell, name == "vfsname"),
"changes" => shell.show_changes = truthy(arguments.first().copied()),
"eqp" => shell.explain_plan = truthy(arguments.first().copied()),
"read" => read(shell, &arguments),
"dump" => dump(shell, &arguments),
"import" => crate::import::import(shell, &arguments),
"backup" | "save" => backup(shell, &arguments),
"clone" => clone(shell, &arguments),
"timeout" => timeout(shell, &arguments),
"log" => log(shell, &arguments),
"load" => load_extension(shell, &arguments),
"progress" => progress(shell, &arguments),
"restore" => restore(shell, &arguments),
"parameter" => parameter(shell, &arguments),
"recover" => crate::diagnose::recover(shell, &arguments),
"sha3sum" => crate::diagnose::sha3sum(shell, &arguments),
"limit" | "limits" => crate::diagnose::limit(shell, &arguments),
"selftest" => crate::diagnose::selftest(shell, &arguments),
"lint" => crate::diagnose::lint(shell, &arguments),
"dbconfig" => crate::dbconfig::dbconfig(shell, &arguments),
"auth" => crate::commands::auth(shell, &arguments),
"connection" => crate::commands::connection(shell, &arguments),
"imposter" => crate::commands::imposter(shell, &arguments),
"cd" => crate::commands::cd(shell, &arguments),
"shell" | "system" => crate::commands::system(shell, &arguments),
"crlf" => crate::commands::crlf(shell, &arguments),
"prompt" => crate::commands::prompt(shell, &arguments),
"explain" => crate::commands::explain(shell, &arguments),
"nonce" => crate::commands::nonce(shell, &arguments),
"testcase" => crate::commands::testcase(shell, &arguments),
"check" => crate::commands::check(shell, &arguments),
"scanstats" => crate::commands::scanstats(shell, &arguments),
"trace" => crate::commands::trace(shell, &arguments),
"dbinfo" => crate::diagnose::dbinfo(shell, &arguments),
"dbtotxt" => crate::diagnose::dbtotxt(shell),
"intck" => crate::diagnose::intck(shell, &arguments),
"filectrl" => crate::diagnose::filectrl(shell, &arguments),
"excel" => crate::commands::viewer(shell, false),
"www" => crate::commands::viewer(shell, true),
"version" => version(shell),
"show" => show(shell),
"nullvalues" => shell.complain("Error: unknown command; try .help"),
_ => shell.complain(&format!(
"Error: unknown command or invalid arguments: \"{name}\". Enter \".help\" for help"
)),
}
}
fn clone(shell: &mut Shell, arguments: &[&str]) {
let named = shell.column(
"SELECT name FROM sqlite_master WHERE type IN ('table','index') AND name NOT LIKE 'sqlite_%' ORDER BY rowid",
);
backup(shell, arguments);
for name in named {
shell.say(&format!("{name}... done"));
}
}
fn timeout(shell: &mut Shell, arguments: &[&str]) {
let milliseconds = arguments
.first()
.and_then(|word| word.parse::<i64>().ok())
.unwrap_or(0)
.max(0);
let _ = shell.collect(&format!("PRAGMA busy_timeout = {milliseconds}"));
}
fn log(shell: &mut Shell, arguments: &[&str]) {
shell.log_to = arguments.first().map(|word| (*word).to_string());
}
fn load_extension(shell: &mut Shell, arguments: &[&str]) {
if arguments.is_empty() {
shell.complain("Usage: .load FILE ?ENTRYPOINT?");
return;
}
shell.complain("Error: The specified module could not be found.");
}
fn progress(shell: &mut Shell, arguments: &[&str]) {
let mut interval = 0u64;
for word in arguments {
match *word {
"--once" => shell.progress_once = true,
"--quiet" | "-q" => shell.progress_quiet = true,
"--reset" => {
shell.progress_once = false;
shell.progress_quiet = false;
shell.progress_limit = 0;
}
"--limit" => {}
other => match other.parse::<u64>() {
Ok(number) if shell.progress_pending_limit => {
shell.progress_limit = number;
shell.progress_pending_limit = false;
}
Ok(number) => interval = number,
Err(_) => {
shell.complain(&format!("Error: unknown option: \"{other}\""));
return;
}
},
}
if *word == "--limit" {
shell.progress_pending_limit = true;
}
}
shell.progress_interval = interval;
}
fn parameter(shell: &mut Shell, arguments: &[&str]) {
match arguments.first().copied().unwrap_or("list") {
"init" => {}
"clear" => shell.parameters.clear(),
"list" => {
let listed: Vec<(String, String)> = shell
.parameters
.iter()
.map(|(name, value)| (name.clone(), literal(value)))
.collect();
let width = listed
.iter()
.map(|(name, _)| name.chars().count())
.max()
.unwrap_or(0);
for (name, value) in listed {
let padding = " ".repeat(width.saturating_sub(name.chars().count()));
shell.say(&format!("{name}{padding} {value}"));
}
}
"unset" => {
let Some(name) = arguments.get(1) else {
shell.complain("Error: .parameter unset needs a name");
return;
};
shell.parameters.remove(*name);
}
"set" => {
let (Some(name), Some(value)) = (arguments.get(1), arguments.get(2)) else {
shell.complain("Error: .parameter set needs a name and a value");
return;
};
let expression = if value.parse::<f64>().is_ok() {
(*value).to_string()
} else {
format!("'{}'", value.replace('\'', "''"))
};
match shell.collect(&format!("SELECT {expression}")) {
Ok((_, rows)) => {
let held = rows
.first()
.and_then(|row| row.first())
.cloned()
.unwrap_or(Value::Null);
shell.parameters.insert((*name).to_string(), held);
}
Err(failure) => shell.complain(&format!("Error: {}", failure.message)),
}
}
other => shell.complain(&format!(
"Error: unknown .parameter subcommand: \"{other}\""
)),
}
}
fn without_terminator(line: &str) -> &str {
let trimmed = line.trim_end();
trimmed.strip_suffix(';').unwrap_or(trimmed)
}
fn split(line: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut started = false;
for character in line.chars() {
match quote {
Some(open) if character == open => {
quote = None;
words.push(core::mem::take(&mut current));
started = false;
}
Some(_) => current.push(character),
None if character == '\'' || character == '"' => {
if started {
words.push(core::mem::take(&mut current));
}
quote = Some(character);
started = true;
}
None if character.is_whitespace() => {
if started {
words.push(core::mem::take(&mut current));
started = false;
}
}
None => {
current.push(character);
started = true;
}
}
}
if started {
words.push(current);
}
words
}
pub fn truthy(argument: Option<&str>) -> bool {
match argument {
None => true,
Some(text) => matches!(
text.to_ascii_lowercase().as_str(),
"on" | "yes" | "true" | "1"
),
}
}
fn help(shell: &mut Shell, arguments: &[&str]) {
let mut lines: Vec<String> = Vec::new();
let matched = crate::help::show_help(arguments.first().copied(), &mut |line| {
lines.push(line.to_string())
});
for line in lines {
shell.say(&line);
}
if matched == 0 {
if let Some(asked) = arguments.first() {
shell.say(&format!("Nothing matches '{asked}'"));
}
}
}
fn open(shell: &mut Shell, arguments: &[&str]) {
let named = arguments.first().copied().unwrap_or(":memory:");
if shell.safe && named != ":memory:" && !named.is_empty() {
shell.complain("Error: cannot open disk-based database files in safe mode");
return;
}
let Some(path) = confine_path(shell, named) else {
return;
};
let path = path.as_str();
if let Err(message) = shell.reopen(path) {
shell.complain(&format!(
"Error: unable to open database \"{path}\": {message}"
));
}
}
fn databases(shell: &mut Shell) {
let Ok((_, rows)) = shell.collect("PRAGMA database_list") else {
shell.complain("Error: could not read the database list");
return;
};
for row in rows {
let name = text_of(row.get(1));
let file = text_of(row.get(2));
shell.say(&format!("{name}: {file} r/w"));
}
}
fn text_of(value: Option<&Value<'static>>) -> String {
match value {
Some(Value::Text(text)) => String::from_utf8_lossy(text.raw()).into_owned(),
Some(Value::Null) | None => String::new(),
Some(other) => literal(other),
}
}
fn tables(shell: &mut Shell, arguments: &[&str]) {
let mut sql = String::from(
"SELECT name FROM sqlite_master WHERE type IN ('table','view') \
AND name NOT LIKE 'sqlite_%'",
);
if let Some(pattern) = arguments.first() {
sql.push_str(&format!(" AND name LIKE {}", literal_text(pattern)));
}
sql.push_str(" ORDER BY name");
let names = shell.column(&sql);
if names.is_empty() {
return;
}
for line in columnise(&names) {
shell.say(&line);
}
}
const SCREEN: usize = 80;
const GUTTER: usize = 5;
fn columnise(names: &[String]) -> Vec<String> {
if names.is_empty() {
return Vec::new();
}
for rows in 1..=names.len() {
let columns = names.len().div_ceil(rows);
let widths: Vec<usize> = (0..columns)
.map(|column| {
names
.iter()
.skip(column.saturating_mul(rows))
.take(rows)
.map(|name| name.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let total: usize = widths
.iter()
.enumerate()
.map(|(at, width)| {
if at.saturating_add(1) == columns {
*width
} else {
width.saturating_add(GUTTER)
}
})
.sum();
if total > SCREEN && rows < names.len() {
continue;
}
let mut lines = Vec::with_capacity(rows);
for row in 0..rows {
let mut line = String::new();
for column in 0..columns {
let Some(name) = names.get(column.saturating_mul(rows).saturating_add(row)) else {
continue;
};
if !line.is_empty() {
line.push_str(&" ".repeat(GUTTER));
}
line.push_str(name);
let width = widths.get(column).copied().unwrap_or(0);
let last = column.saturating_add(1) == columns
|| names
.get(
column
.saturating_add(1)
.saturating_mul(rows)
.saturating_add(row),
)
.is_none();
if !last {
line.push_str(&" ".repeat(width.saturating_sub(name.chars().count())));
}
}
if !line.is_empty() {
lines.push(line);
}
}
return lines;
}
Vec::new()
}
fn indexes(shell: &mut Shell, arguments: &[&str]) {
let mut sql = String::from("SELECT name FROM sqlite_master WHERE type = 'index'");
if let Some(pattern) = arguments.first() {
sql.push_str(&format!(
" AND name LIKE {}",
literal_text(&format!("%{pattern}%"))
));
}
sql.push_str(" AND name NOT LIKE 'sqlite_%' ORDER BY name");
let names = shell.column(&sql);
if names.is_empty() {
return;
}
for line in columnise(&names) {
shell.say(&line);
}
}
fn schema(shell: &mut Shell, arguments: &[&str]) {
let mut sql = String::from("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL");
if let Some(pattern) = arguments.first() {
let quoted = literal_text(pattern);
sql.push_str(&format!(
" AND (name LIKE {quoted} OR tbl_name LIKE {quoted})"
));
}
sql.push_str(" ORDER BY rowid");
let Ok((_, rows)) = shell.collect(&sql) else {
return;
};
for row in rows {
let kind = text_of(row.first());
let name = text_of(row.get(1));
let statement = text_of(row.get(2));
if kind == "view" {
let listed = view_columns(shell, &name);
shell.say(&format!("{statement}\n/* {name}({listed}) */;"));
continue;
}
shell.say(&format!("{statement};"));
}
}
fn view_columns(shell: &Shell, name: &str) -> String {
let sql = format!("PRAGMA table_info({})", quote_identifier(name));
let Ok((_, rows)) = shell.collect(&sql) else {
return String::new();
};
rows.iter()
.map(|row| text_of(row.get(1)))
.collect::<Vec<String>>()
.join(",")
}
fn quote_identifier(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn full_schema(shell: &mut Shell) {
schema(shell, &[]);
let exists = shell.scalar("SELECT count(*) FROM sqlite_master WHERE name = 'sqlite_stat1'");
if exists.as_deref() == Some("0") || exists.is_none() {
shell.say("/* No STAT tables available */");
return;
}
let layout = core::mem::replace(
&mut shell.layout,
Layout {
mode: crate::render::Mode::Insert,
table: "sqlite_stat1".to_string(),
..Layout::default()
},
);
shell.run("SELECT * FROM sqlite_stat1");
shell.layout = layout;
}
fn literal_text(text: &str) -> String {
format!("'{}'", text.replace('\'', "''"))
}
fn mode(shell: &mut Shell, arguments: &[&str]) {
let Some(name) = arguments.first() else {
let current = format!("current output mode: {}", shell.layout.mode.name());
shell.say(¤t);
return;
};
match mode_named(name) {
Err(message) => shell.complain(&message),
Ok(mode) => {
shell.layout.mode = mode;
shell.layout.separator = mode.separator().to_string();
shell.layout.row_separator = if mode == crate::render::Mode::Csv {
"\r\n".to_string()
} else {
"\n".to_string()
};
if matches!(
mode,
crate::render::Mode::Column
| crate::render::Mode::Markdown
| crate::render::Mode::Table
| crate::render::Mode::Box
| crate::render::Mode::Html
) {
shell.layout.headers = true;
}
if let Some(table) = arguments.get(1) {
shell.layout.table = (*table).to_string();
}
}
}
}
fn separator(shell: &mut Shell, arguments: &[&str]) {
let Some(column) = arguments.first() else {
shell.complain("Usage: .separator COL ?ROW?");
return;
};
shell.layout.separator = resolve_backslashes(column);
if let Some(row) = arguments.get(1) {
shell.layout.row_separator = resolve_backslashes(row);
}
}
fn resolve_backslashes(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut characters = text.chars();
while let Some(character) = characters.next() {
if character != '\\' {
out.push(character);
continue;
}
match characters.next() {
Some('a') => out.push('\u{7}'),
Some('b') => out.push('\u{8}'),
Some('f') => out.push('\u{c}'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('v') => out.push('\u{b}'),
Some('x') => {
let digits: String = characters.clone().take(2).collect();
match u32::from_str_radix(&digits, 16)
.ok()
.and_then(char::from_u32)
{
Some(resolved) if digits.len() == 2 => {
out.push(resolved);
characters.next();
characters.next();
}
_ => out.push('x'),
}
}
Some(other) => out.push(other),
None => out.push('\\'),
}
}
out
}
fn width(shell: &mut Shell, arguments: &[&str]) {
shell.layout.widths = arguments
.iter()
.map(|word| word.parse::<usize>().unwrap_or(0))
.collect();
}
fn output(shell: &mut Shell, arguments: &[&str], once: bool) {
let named = arguments.first().copied().filter(|path| *path != "stdout");
let confined = match named {
None => None,
Some(named) => match confine_path(shell, named) {
Some(path) => Some(path),
None => return,
},
};
if let Err(message) = shell.redirect(confined.as_deref(), once) {
shell.complain(&format!(
"Error: cannot open \"{}\": {message}",
confined.unwrap_or_default()
));
}
}
fn read(shell: &mut Shell, arguments: &[&str]) {
let Some(named) = arguments.first() else {
shell.complain("Error: .read requires a file name");
return;
};
let Some(path) = confine_path(shell, named) else {
return;
};
let Ok(text) = std::fs::read_to_string(&path) else {
shell.complain(&format!("Error: cannot open \"{path}\""));
return;
};
let lines: Vec<String> = text.lines().map(str::to_string).collect();
drive(shell, lines.into_iter());
}
fn dump(shell: &mut Shell, arguments: &[&str]) {
let confined = match arguments.first().copied() {
None => None,
Some(named) => match confine_path(shell, named) {
Some(path) => Some(path),
None => return,
},
};
crate::dump::dump(shell, confined.as_deref());
}
fn backup(shell: &mut Shell, arguments: &[&str]) {
let (_, named) = database_and_file(arguments);
let Some(named) = named else {
shell.complain("Error: .backup requires a file name");
return;
};
let Some(path) = confine_path(shell, named) else {
return;
};
let path = path.as_str();
if let Err(message) = shell.backup_to(path) {
shell.complain(&format!("Error: {message}"));
}
}
fn restore(shell: &mut Shell, arguments: &[&str]) {
let (_, named) = database_and_file(arguments);
let Some(named) = named else {
shell.complain("Error: .restore requires a file name");
return;
};
let Some(path) = confine_path(shell, named) else {
return;
};
let path = path.as_str();
if let Err(message) = shell.reopen(path) {
shell.complain(&format!("Error: {message}"));
}
}
fn database_and_file<'a>(arguments: &[&'a str]) -> (&'a str, Option<&'a str>) {
match arguments {
[file] => ("main", Some(file)),
[database, file, ..] => (database, Some(file)),
_ => ("main", None),
}
}
fn version(shell: &mut Shell) {
let library = shell
.scalar("SELECT sqlite_version()")
.unwrap_or_else(|| "unknown".to_string());
shell.say(&format!("SQLite {library}"));
shell.say("inillucent (a first-party engine implementing the SQLite ABI)");
}
fn show(shell: &mut Shell) {
let lines = vec![
format!(" echo: {}", on_off(shell.echo)),
format!(" eqp: {}", on_off(shell.explain_plan)),
format!(" explain: {}", shell.explain_mode.name()),
format!(" headers: {}", on_off(shell.layout.headers)),
format!(" mode: {}", shell.layout.mode.name()),
format!(" nullvalue: \"{}\"", shell.layout.null),
format!(" output: {}", shell.output_target()),
format!("colseparator: \"{}\"", shell.layout.separator),
format!("rowseparator: \"{}\"", escape(&shell.layout.row_separator)),
format!(" stats: {}", on_off(shell.stats)),
format!(" width: {}", widths_text(&shell.layout.widths)),
format!(" filename: {}", shell.path().to_string()),
];
for line in lines {
shell.say(&line);
}
}
fn on_off(value: bool) -> &'static str {
if value {
"on"
} else {
"off"
}
}
fn escape(text: &str) -> String {
text.replace('\n', "\\n").replace('\t', "\\t")
}
fn widths_text(widths: &[usize]) -> String {
widths
.iter()
.map(usize::to_string)
.collect::<Vec<String>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn arguments_split_the_way_a_shell_splits_them() {
assert_eq!(split(".mode csv"), vec![".mode", "csv"]);
assert_eq!(
split(".output \"my file.txt\""),
vec![".output", "my file.txt"]
);
assert_eq!(split(".separator \" | \""), vec![".separator", " | "]);
assert_eq!(split(".print"), vec![".print"]);
}
#[test]
fn a_missing_argument_means_on() {
assert!(truthy(None));
assert!(truthy(Some("on")));
assert!(truthy(Some("YES")));
assert!(!truthy(Some("off")));
assert!(!truthy(Some("nonsense")));
}
}