use std::collections::VecDeque;
use rustyline::{DefaultEditor, error::ReadlineError};
const SHELL_PROMPT: &str = "icydb> ";
const SHELL_CONTINUATION_PROMPT: &str = " -> ";
pub(super) enum ShellInput {
Sql(String),
Help,
Exit,
}
enum ShellTopLevelInput {
Blank,
Help,
Exit,
Sql,
}
pub(super) fn is_shell_help_command(input: &str) -> bool {
let normalized = input.trim().trim_end_matches(';').trim();
["?", "help", "\\?", "\\help"]
.into_iter()
.any(|command| normalized.eq_ignore_ascii_case(command))
}
pub(super) fn is_shell_exit_command(input: &str) -> bool {
["\\q", "quit", "exit"]
.into_iter()
.any(|command| input.eq_ignore_ascii_case(command))
}
pub(super) const fn shell_help_text() -> &'static str {
"meta commands:
? / help show this help
\\q / quit / exit quit the interactive shell
SQL strings:
Escape a quote by doubling it (''); backslashes are literal.
Multiline strings retain their whitespace and semicolons.
examples:
SELECT name FROM character;
EXPLAIN EXECUTION SELECT name FROM character;
CREATE INDEX character_level_idx ON character (level);
SHOW INDEXES FROM character;
DESCRIBE character;
DESCRIBE character VERBOSE;
SHOW RELATIONS FROM character;
DROP INDEX character_level_idx ON character;"
}
pub(super) fn read_statement(
editor: &mut DefaultEditor,
pending_sql: &mut VecDeque<String>,
partial_statement: &mut String,
) -> Result<ShellInput, String> {
if let Some(sql) = pending_sql.pop_front() {
return Ok(ShellInput::Sql(sql));
}
let mut prompt = shell_prompt(partial_statement);
loop {
match editor.readline(prompt) {
Ok(line) => {
if partial_statement_is_empty(partial_statement) {
match top_level_shell_input(line.as_str()) {
ShellTopLevelInput::Blank => {
prompt = SHELL_PROMPT;
continue;
}
ShellTopLevelInput::Exit => return Ok(ShellInput::Exit),
ShellTopLevelInput::Help => return Ok(ShellInput::Help),
ShellTopLevelInput::Sql => {}
}
}
append_shell_statement_line(partial_statement, line.as_str());
pending_sql.extend(drain_complete_shell_statements(partial_statement));
if let Some(sql) = pending_sql.pop_front() {
return Ok(ShellInput::Sql(sql));
}
prompt = shell_prompt(partial_statement);
}
Err(ReadlineError::Interrupted) => {
clear_shell_input_state(pending_sql, partial_statement);
prompt = shell_prompt(partial_statement);
}
Err(ReadlineError::Eof) => return Ok(shell_input_at_eof(partial_statement)),
Err(err) => return Err(err.to_string()),
}
}
}
fn partial_statement_is_empty(partial_statement: &str) -> bool {
partial_statement.trim().is_empty()
}
fn shell_prompt(partial_statement: &str) -> &'static str {
if partial_statement_is_empty(partial_statement) {
SHELL_PROMPT
} else {
SHELL_CONTINUATION_PROMPT
}
}
fn append_shell_statement_line(partial_statement: &mut String, line: &str) {
if !partial_statement.is_empty() {
partial_statement.push('\n');
}
partial_statement.push_str(line);
}
fn clear_shell_input_state(pending_sql: &mut VecDeque<String>, partial_statement: &mut String) {
partial_statement.clear();
pending_sql.clear();
}
fn shell_input_at_eof(partial_statement: &mut String) -> ShellInput {
if !partial_statement_is_empty(partial_statement) {
eprintln!("[icydb sql] discarded unterminated statement");
}
partial_statement.clear();
println!();
ShellInput::Exit
}
fn top_level_shell_input(line: &str) -> ShellTopLevelInput {
let line = line.trim();
if line.is_empty() {
return ShellTopLevelInput::Blank;
}
if is_shell_exit_command(line) {
return ShellTopLevelInput::Exit;
}
if is_shell_help_command(line) {
return ShellTopLevelInput::Help;
}
ShellTopLevelInput::Sql
}
pub(super) fn drain_complete_shell_statements(statement: &mut String) -> VecDeque<String> {
let mut complete = VecDeque::<String>::new();
let mut start = 0usize;
let mut in_single_quote = false;
let mut chars = statement.char_indices().peekable();
while let Some((offset, ch)) = chars.next() {
if ch == '\'' {
let next_is_quote = chars.peek().is_some_and(|(_, next)| *next == '\'');
if in_single_quote && next_is_quote {
let _ = chars.next();
continue;
}
in_single_quote = !in_single_quote;
continue;
}
if ch == ';' && !in_single_quote {
let end = offset + ch.len_utf8();
let candidate = statement[start..end].trim();
if candidate != ";" {
complete.push_back(candidate.to_string());
}
start = end;
}
}
statement.drain(..start);
if partial_statement_is_empty(statement) {
statement.clear();
}
complete
}
#[cfg(test)]
mod tests;