use polars::prelude::Schema;
pub const VIEW_VERBS: &[&str] = &["select", "hide", "expand", "filter", "sort", "reset"];
pub const FILE_VERBS: &[&str] = &["w", "wq", "q", "x"];
const OPERATORS: &[&str] = &["=", "!=", "<", "<=", ">", ">=", "~", "!~"];
const SLOTS: &[&str] = &["select", "filter", "sort"];
#[derive(Debug, PartialEq)]
pub struct Completion {
pub head: String,
pub word: String,
pub options: Vec<String>,
}
impl Completion {
pub fn extended(&self) -> String {
if let [only] = self.options.as_slice() {
return format!("{}{only} ", self.head);
}
let shared = common_prefix(&self.options);
if shared.len() > self.word.len() && shared.starts_with(&self.word) {
format!("{}{shared}", self.head)
} else {
format!("{}{}", self.head, self.word)
}
}
pub fn with(&self, index: usize) -> String {
match self.options.get(index) {
Some(option) => format!("{}{option} ", self.head),
None => format!("{}{}", self.head, self.word),
}
}
}
pub fn complete(line: &str, schema: &Schema) -> Option<Completion> {
let (head, word) = split_last_word(line);
let stem = word.trim_start_matches('"');
let lower = stem.to_lowercase();
let options: Vec<String> = candidates_for(head, schema)
.into_iter()
.filter(|(bare, _)| bare.to_lowercase().starts_with(&lower))
.map(|(_, written)| written)
.collect();
(!options.is_empty()).then(|| Completion {
head: head.to_string(),
word: word.to_string(),
options,
})
}
fn candidates_for(head: &str, schema: &Schema) -> Vec<(String, String)> {
let words: Vec<&str> = head.split_whitespace().collect();
let columns = || -> Vec<(String, String)> {
schema
.iter_names()
.map(|n| (n.to_string(), quoted(n.as_str())))
.collect()
};
let plain = |list: &[&str]| -> Vec<(String, String)> {
list.iter()
.map(|s| (s.to_string(), s.to_string()))
.collect()
};
let Some(verb) = words.first() else {
return plain(VIEW_VERBS)
.into_iter()
.chain(plain(FILE_VERBS))
.collect();
};
match *verb {
"select" | "hide" | "expand" | "sort" => columns(),
"reset" => plain(SLOTS),
"filter" => {
match (words.len() - 1) % 4 {
0 => columns(),
1 => plain(OPERATORS),
2 => Vec::new(),
_ => plain(&["and"]),
}
}
_ => Vec::new(),
}
}
fn quoted(name: &str) -> String {
let needs = name.is_empty()
|| name == "*"
|| name.ends_with('-')
|| name.chars().any(|c| c.is_whitespace() || c == '"');
if needs {
format!("\"{name}\"")
} else {
name.to_string()
}
}
fn split_last_word(line: &str) -> (&str, &str) {
match line.rfind(char::is_whitespace) {
Some(at) => line.split_at(at + 1),
None => ("", line),
}
}
fn common_prefix(options: &[String]) -> String {
let Some(first) = options.first() else {
return String::new();
};
let mut end = first.len();
for other in &options[1..] {
end = end.min(
first
.char_indices()
.zip(other.char_indices())
.take_while(|((_, a), (_, b))| a == b)
.map(|((i, a), _)| i + a.len_utf8())
.last()
.unwrap_or(0),
);
}
first[..end].to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::view;
use polars::prelude::{DataType, PlSmallStr};
fn schema() -> Schema {
Schema::from_iter([
(PlSmallStr::from_static("name"), DataType::String),
(PlSmallStr::from_static("count"), DataType::Int64),
(PlSmallStr::from_static("category"), DataType::String),
(PlSmallStr::from_static("release date"), DataType::String),
])
}
fn options(line: &str) -> Vec<String> {
complete(line, &schema())
.map(|c| c.options)
.unwrap_or_default()
}
fn extended(line: &str) -> String {
complete(line, &schema())
.map(|c| c.extended())
.unwrap_or_else(|| line.to_string())
}
#[test]
fn every_offered_view_verb_is_one_the_parser_knows() {
let schema = schema();
for verb in VIEW_VERBS {
let refusal = view::parse(verb, &schema)
.err()
.map(|e| e.message)
.unwrap_or_default();
assert!(
!refusal.starts_with("not a command"),
"completion offers :{verb}, which the parser does not know"
);
}
assert!(
view::parse("frobnicate", &schema)
.unwrap_err()
.message
.starts_with("not a command")
);
}
#[test]
fn the_first_word_completes_to_a_verb() {
assert_eq!(extended("sel"), "select ");
assert_eq!(options("s"), ["select", "sort"]);
assert!(options("").len() > 5);
}
#[test]
fn a_column_argument_completes_to_a_column() {
assert_eq!(extended("select na"), "select name ");
assert_eq!(options("select c"), ["count", "category"]);
assert_eq!(extended("hide co"), "hide count ");
}
#[test]
fn a_name_that_needs_quoting_arrives_quoted() {
assert_eq!(extended("select rel"), "select \"release date\" ");
assert_eq!(extended("select \"rel"), "select \"release date\" ");
}
#[test]
fn several_candidates_extend_only_as_far_as_they_agree() {
assert_eq!(extended("select c"), "select c", "nothing to add yet");
assert_eq!(extended("select ca"), "select category ");
}
#[test]
fn completion_never_shortens_what_was_typed() {
let c = complete("select re", &schema()).unwrap();
assert!(
c.extended().ends_with("\"release date\" "),
"{}",
c.extended()
);
let c = Completion {
head: "select ".to_string(),
word: "xyz".to_string(),
options: vec!["abc".to_string(), "abd".to_string()],
};
assert_eq!(c.extended(), "select xyz", "left exactly as typed");
}
#[test]
fn a_filter_cycles_through_column_operator_value_and_and() {
assert_eq!(options("filter co"), ["count"]);
assert_eq!(options("filter count "), OPERATORS);
assert!(options("filter count > ").is_empty(), "a value is data");
assert_eq!(options("filter count > 10 "), ["and"]);
assert_eq!(options("filter count > 10 and na"), ["name"]);
}
#[test]
fn reset_offers_the_slots() {
assert_eq!(options("reset "), SLOTS);
assert_eq!(extended("reset fi"), "reset filter ");
}
#[test]
fn an_unknown_verb_has_nothing_to_offer() {
assert!(complete("w some", &schema()).is_none());
assert!(complete("select zzz", &schema()).is_none());
}
#[test]
fn stepping_through_candidates_replaces_the_word() {
let c = complete("select c", &schema()).unwrap();
assert_eq!(c.with(0), "select count ");
assert_eq!(c.with(1), "select category ");
assert_eq!(c.with(9), "select c", "out of range leaves it alone");
}
}