spacetimedb-cli 0.5.0

A command line interface for SpacetimeDB
Documentation
use crate::api::ClientApi;
use crate::sql::{parse_req, run_sql};
use crate::Config;
use clap::{Arg, ArgAction, ArgMatches};
use colored::*;
use std::io::Write;

use rustyline::completion::Completer;
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::history::DefaultHistory;
use rustyline::validate::{MatchingBracketValidator, Validator};
use rustyline::{Editor, Helper};

use syntect::easy::HighlightLines;
use syntect::highlighting::{Theme, ThemeSet};
use syntect::parsing::{SyntaxDefinition, SyntaxSet, SyntaxSetBuilder};
use syntect::util::LinesWithEndings;

static SQL_SYNTAX: &str = include_str!("../../tools/sublime/SpaceTimeDbSQL.sublime-syntax");
static SYNTAX_NAME: &str = "SQL (SpaceTimeDb)";

static AUTO_COMPLETE: &str = "\
true
false
select
from
insert
into
values
update,
delete,
create,
where
join
sort by
.exit
.clear
";

pub fn cli() -> clap::Command {
    clap::Command::new("repl").about("Runs an interactive command prompt.")
    .arg(
        Arg::new("database")
            .required(true)
            .help("The domain or address of the database you would like to query"),
    )
    .arg(
        Arg::new("as_identity")
            .long("as-identity")
            .short('i')
            .conflicts_with("anon_identity")
            .help("The identity to use for querying the database")
            .long_help("The identity to use for querying the database. If no identity is provided, the default one will be used."),
    )
    .arg(
        Arg::new("anon_identity")
            .long("anon-identity")
            .short('a')
            .conflicts_with("as_identity")
            .action(ArgAction::SetTrue)
            .help("If this flag is present, no identity will be provided when querying the database")
    )
}

pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> {
    let con = parse_req(config, args).await?;
    let database = con.database.clone();
    let mut rl = Editor::<ReplHelper, DefaultHistory>::new().unwrap();
    if rl.load_history(".history.txt").is_err() {
        eprintln!("No previous history.");
    }
    rl.set_helper(Some(ReplHelper::new().unwrap()));

    println!(
        "\
┌──────────────────────────────────────────────────────────┐
│ .exit: Exit the REPL                                     │
│ .clear: Clear the Screen                                 │
│                                                          │
│ Give us feedback in our Discord server:                  │
│    https://discord.gg/w2DVqNZXdN                         │
└──────────────────────────────────────────────────────────┘",
    );

    let api = ClientApi::new(con);

    loop {
        let readline = rl.readline(&format!("🪐{}>", &database).green());
        match readline {
            Ok(line) => match line.as_str() {
                ".exit" => break,
                ".clear" => {
                    //todo: this could not work on windows
                    print!("{esc}[2J{esc}[1;1H", esc = 27 as char);
                    std::io::stdout().flush().ok();
                }
                sql => match run_sql(api.sql(), sql).await {
                    Ok(()) => {
                        rl.add_history_entry(line).ok();
                    }
                    Err(err) => {
                        eprintln!("{}", err.to_string().red())
                    }
                },
            },
            Err(ReadlineError::Interrupted) | Err(ReadlineError::Eof) => {
                println!("\n{}", "Aborted!".red());
                break;
            }
            x => {
                eprintln!("\nUnexpected: {x:?}");
                break;
            }
        }
    }

    rl.save_history(".history.txt").ok();

    Ok(())
}

pub(crate) struct ReplHelper {
    syntaxes: SyntaxSet,
    theme: Theme,
    brackets: MatchingBracketValidator,
}

impl ReplHelper {
    pub fn new() -> Result<Self, ()> {
        let syntax_def = SyntaxDefinition::load_from_str(SQL_SYNTAX, false, Some(SYNTAX_NAME)).unwrap();
        let mut builder = SyntaxSetBuilder::new();
        builder.add(syntax_def);

        let syntaxes = builder.build();

        let _ps = SyntaxSet::load_defaults_newlines();
        let ts = ThemeSet::load_defaults();
        let theme = ts.themes["base16-ocean.dark"].clone();

        Ok(ReplHelper {
            syntaxes,
            theme,
            brackets: MatchingBracketValidator::new(),
        })
    }
}

impl Helper for ReplHelper {}

impl Completer for ReplHelper {
    type Candidate = String;

    fn complete(
        &self,
        line: &str,
        pos: usize,
        _: &rustyline::Context<'_>,
    ) -> rustyline::Result<(usize, Vec<Self::Candidate>)> {
        let mut name = String::new();
        let mut name_pos = pos;
        while let Some(char) = line
            .chars()
            .nth(name_pos.wrapping_sub(1))
            .filter(|c| c.is_ascii_alphanumeric() || ['_', '.'].contains(c))
        {
            name.push(char);
            name_pos -= 1;
        }
        if name.is_empty() {
            return Ok((0, vec![]));
        }
        name = name.chars().rev().collect();

        let mut completions: Vec<_> = AUTO_COMPLETE.split('\n').map(str::to_string).collect();
        completions = completions
            .iter()
            .filter_map(|it| it.starts_with(&name).then(|| it.clone()))
            .collect();

        Ok((name_pos, completions))
    }
}

impl Hinter for ReplHelper {
    type Hint = String;

    fn hint(&self, line: &str, pos: usize, ctx: &rustyline::Context<'_>) -> Option<Self::Hint> {
        if line.len() > pos {
            return None;
        }
        if let Ok((mut completion_pos, completions)) = self.complete(line, pos, ctx) {
            if completions.is_empty() {
                return None;
            }
            let mut hint = completions[0].clone();
            while completion_pos < pos {
                if hint.is_empty() {
                    return None;
                }
                hint.remove(0);
                completion_pos += 1;
            }
            Some(hint)
        } else {
            None
        }
    }
}

impl Highlighter for ReplHelper {
    fn highlight<'l>(&self, line: &'l str, _: usize) -> std::borrow::Cow<'l, str> {
        let mut h = HighlightLines::new(self.syntaxes.find_syntax_by_name(SYNTAX_NAME).unwrap(), &self.theme);
        let mut out = String::new();
        for line in LinesWithEndings::from(line) {
            let ranges = h.highlight_line(line, &self.syntaxes).unwrap();
            let escaped = syntect::util::as_24_bit_terminal_escaped(&ranges[..], false);
            out += &escaped;
        }
        std::borrow::Cow::Owned(out)
    }

    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(&'s self, prompt: &'p str, _: bool) -> std::borrow::Cow<'b, str> {
        std::borrow::Cow::Owned(prompt.green().to_string())
    }

    fn highlight_hint<'h>(&self, hint: &'h str) -> std::borrow::Cow<'h, str> {
        std::borrow::Cow::Owned(hint.bright_black().to_string())
    }

    fn highlight_candidate<'c>(&self, candidate: &'c str, _: rustyline::CompletionType) -> std::borrow::Cow<'c, str> {
        std::borrow::Cow::Owned(candidate.bright_cyan().to_string())
    }

    fn highlight_char(&self, _: &str, _: usize) -> bool {
        true
    }
}

impl Validator for ReplHelper {
    fn validate(
        &self,
        ctx: &mut rustyline::validate::ValidationContext,
    ) -> rustyline::Result<rustyline::validate::ValidationResult> {
        self.brackets.validate(ctx)
    }
}