icydb-cli 0.296.9

Developer CLI tools for IcyDB
//! Module: shell input parsing.
//! Responsibility: classify interactive shell input and split complete SQL statements.
//! Does not own: SQL execution, endpoint routing, or rendered output.
//! Boundary: exposes parsed shell actions to the shell runner and test-only helpers via the parent module.

use std::collections::VecDeque;

use rustyline::{DefaultEditor, error::ReadlineError};

const SHELL_PROMPT: &str = "icydb> ";
const SHELL_CONTINUATION_PROMPT: &str = "    -> ";

///
/// ShellInput
///
/// ShellInput classifies one top-level interactive shell action before the CLI
/// decides whether to execute SQL, print local help text, or exit the shell.
///

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> {
    // Drain any previously pasted complete statements before blocking for more
    // terminal input so one bracketed paste can execute multiple SQL commands.
    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()) {
                        // Ignore top-level blank input so pressing Enter on an
                        // empty prompt simply reprompts instead of executing empty SQL.
                        ShellTopLevelInput::Blank => {
                            prompt = SHELL_PROMPT;
                            continue;
                        }
                        ShellTopLevelInput::Exit => return Ok(ShellInput::Exit),
                        ShellTopLevelInput::Help => return Ok(ShellInput::Help),
                        ShellTopLevelInput::Sql => {}
                    }
                }

                // Split one pasted batch into every complete top-level
                // semicolon-terminated statement while preserving any trailing
                // incomplete remainder for the continuation prompt.
                // Line-edge bytes may belong to an open literal. Classify meta
                // commands separately; never normalize text sent to 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 {
    // Only semicolon-terminated shell input is submitted. EOF is the advertised
    // exit control and must never turn an unfinished buffer into a canister call.
    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
}

// Split every complete top-level SQL statement from one shell buffer while
// preserving quoted semicolons and any trailing incomplete remainder.
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;
    // The scan only needs one-character lookahead. Keep byte offsets directly
    // from the UTF-8 iterator without materializing an input-sized offset list.
    let mut chars = statement.char_indices().peekable();

    while let Some((offset, ch)) = chars.next() {
        // Match the SQL lexer: doubled quotes escape a quote; a backslash
        // has no effect on string or statement boundaries.
        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;
        }
    }

    // The unfinished suffix may end inside a literal, including on spaces or
    // a blank line. Remove only completed statements, then discard a suffix
    // only when it contains no SQL text at all.
    statement.drain(..start);
    if partial_statement_is_empty(statement) {
        statement.clear();
    }

    complete
}

#[cfg(test)]
mod tests;