blob: a8e149b4c5c671f58bc4a4e648d227e6de84ef27 [file] [log] [blame]
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Helper that helps with interactive editing, including multi-line parsing and validation,
//! and auto-completion for file name during creating external table.
use std::borrow::Cow;
use datafusion::common::sql_err;
use datafusion::error::DataFusionError;
use datafusion::sql::parser::{DFParser, Statement};
use datafusion::sql::sqlparser::dialect::dialect_from_str;
use datafusion::sql::sqlparser::parser::ParserError;
use rustyline::completion::Completer;
use rustyline::completion::FilenameCompleter;
use rustyline::completion::Pair;
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::ValidationContext;
use rustyline::validate::ValidationResult;
use rustyline::validate::Validator;
use rustyline::Context;
use rustyline::Helper;
use rustyline::Result;
use crate::highlighter::{NoSyntaxHighlighter, SyntaxHighlighter};
pub struct CliHelper {
completer: FilenameCompleter,
dialect: String,
highlighter: Box<dyn Highlighter>,
}
impl CliHelper {
pub fn new(dialect: &str, color: bool) -> Self {
let highlighter: Box<dyn Highlighter> = if !color {
Box::new(NoSyntaxHighlighter {})
} else {
Box::new(SyntaxHighlighter::new(dialect))
};
Self {
completer: FilenameCompleter::new(),
dialect: dialect.into(),
highlighter,
}
}
pub fn set_dialect(&mut self, dialect: &str) {
if dialect != self.dialect {
self.dialect = dialect.to_string();
}
}
fn validate_input(&self, input: &str) -> Result<ValidationResult> {
if let Some(sql) = input.strip_suffix(';') {
let sql = match unescape_input(sql) {
Ok(sql) => sql,
Err(err) => {
return Ok(ValidationResult::Invalid(Some(format!(
" 🤔 Invalid statement: {err}",
))))
}
};
let dialect = match dialect_from_str(&self.dialect) {
Some(dialect) => dialect,
None => {
return Ok(ValidationResult::Invalid(Some(format!(
" 🤔 Invalid dialect: {}",
self.dialect
))))
}
};
match DFParser::parse_sql_with_dialect(&sql, dialect.as_ref()) {
Ok(statements) if statements.is_empty() => Ok(ValidationResult::Invalid(
Some(" 🤔 You entered an empty statement".to_string()),
)),
Ok(_statements) => Ok(ValidationResult::Valid(None)),
Err(err) => Ok(ValidationResult::Invalid(Some(format!(
" 🤔 Invalid statement: {err}",
)))),
}
} else if input.starts_with('\\') {
// command
Ok(ValidationResult::Valid(None))
} else {
Ok(ValidationResult::Incomplete)
}
}
}
impl Default for CliHelper {
fn default() -> Self {
Self::new("generic", false)
}
}
impl Highlighter for CliHelper {
fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
self.highlighter.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize) -> bool {
self.highlighter.highlight_char(line, pos)
}
}
impl Hinter for CliHelper {
type Hint = String;
}
/// returns true if the current position is after the open quote for
/// creating an external table.
fn is_open_quote_for_location(line: &str, pos: usize) -> bool {
let mut sql = line[..pos].to_string();
sql.push('\'');
if let Ok(stmts) = DFParser::parse_sql(&sql) {
if let Some(Statement::CreateExternalTable(_)) = stmts.back() {
return true;
}
}
false
}
impl Completer for CliHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &Context<'_>,
) -> std::result::Result<(usize, Vec<Pair>), ReadlineError> {
if is_open_quote_for_location(line, pos) {
self.completer.complete(line, pos, ctx)
} else {
Ok((0, Vec::with_capacity(0)))
}
}
}
impl Validator for CliHelper {
fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult> {
let input = ctx.input().trim_end();
self.validate_input(input)
}
}
impl Helper for CliHelper {}
/// Unescape input string from readline.
///
/// The data read from stdio will be escaped, so we need to unescape the input before executing the input
pub fn unescape_input(input: &str) -> datafusion::error::Result<String> {
let mut chars = input.chars();
let mut result = String::with_capacity(input.len());
while let Some(char) = chars.next() {
if char == '\\' {
if let Some(next_char) = chars.next() {
// https://static.rust-lang.org/doc/master/reference.html#literals
result.push(match next_char {
'0' => '\0',
'n' => '\n',
'r' => '\r',
't' => '\t',
'\\' => '\\',
_ => {
return sql_err!(ParserError::TokenizerError(format!(
"unsupported escape char: '\\{}'",
next_char
),))
}
});
}
} else {
result.push(char);
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, Cursor};
use super::*;
fn readline_direct(
mut reader: impl BufRead,
validator: &CliHelper,
) -> Result<ValidationResult> {
let mut input = String::new();
if reader.read_line(&mut input)? == 0 {
return Err(ReadlineError::Eof);
}
validator.validate_input(&input)
}
#[test]
fn unescape_readline_input() -> Result<()> {
let validator = CliHelper::default();
// shoule be valid
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter ',';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\0';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\n';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\r';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\t';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\\';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
// should be invalid
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter ',,';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Invalid(Some(_))));
let result = readline_direct(
Cursor::new(r"create external table test stored as csv location 'data.csv' delimiter '\u{07}';".as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Invalid(Some(_))));
Ok(())
}
#[test]
fn sql_dialect() -> Result<()> {
let mut validator = CliHelper::default();
// shoule be invalid in generic dialect
let result =
readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
assert!(
matches!(result, ValidationResult::Invalid(Some(e)) if e.contains("Invalid statement"))
);
// valid in postgresql dialect
validator.set_dialect("postgresql");
let result =
readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
assert!(matches!(result, ValidationResult::Valid(None)));
Ok(())
}
}