use std::collections::{HashMap, HashSet};
use std::ops::Range;
use std::sync::Arc;
use super::catalog::{Catalog, CatalogEntry, CatalogKind};
use super::config::Engine;
pub const MAX_SUGGESTIONS: usize = 60;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SuggestionKind {
Keyword,
Table,
View,
Column,
Routine,
Schema,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Suggestion {
pub label: String,
pub insert: String,
pub kind: SuggestionKind,
pub detail: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Completions {
pub replace: Range<usize>,
pub prefix: String,
pub items: Vec<Suggestion>,
pub missing: Vec<String>,
}
pub struct Schemas<'a> {
pub current: &'a Catalog,
pub current_database: Option<&'a str>,
pub others: &'a HashMap<String, Arc<Catalog>>,
pub databases: &'a [String],
}
const KEYWORDS: &[&str] = &[
"ADD",
"ALL",
"ALTER",
"AND",
"ANY",
"AS",
"ASC",
"BEGIN",
"BETWEEN",
"BY",
"CASCADE",
"CASE",
"CAST",
"CHECK",
"COALESCE",
"COLUMN",
"COMMIT",
"CONSTRAINT",
"COUNT",
"CREATE",
"CROSS",
"DATABASE",
"DEFAULT",
"DELETE",
"DESC",
"DISTINCT",
"DROP",
"ELSE",
"END",
"EXCEPT",
"EXISTS",
"EXPLAIN",
"FALSE",
"FOREIGN",
"FROM",
"FULL",
"GROUP",
"HAVING",
"IN",
"INDEX",
"INNER",
"INSERT",
"INTERSECT",
"INTO",
"IS",
"JOIN",
"KEY",
"LEFT",
"LIKE",
"LIMIT",
"NOT",
"NULL",
"OFFSET",
"ON",
"OR",
"ORDER",
"OUTER",
"PRIMARY",
"REFERENCES",
"RIGHT",
"ROLLBACK",
"SELECT",
"SET",
"TABLE",
"THEN",
"TRUE",
"TRUNCATE",
"UNION",
"UNIQUE",
"UPDATE",
"USING",
"VALUES",
"VIEW",
"WHEN",
"WHERE",
"WITH",
];
fn engine_keywords(engine: Engine) -> &'static [&'static str] {
match engine {
Engine::Postgres => &[
"ILIKE",
"LATERAL",
"RETURNING",
"SCHEMA",
"SIMILAR",
"VACUUM",
"WINDOW",
],
Engine::MySql => &[
"AUTO_INCREMENT",
"DATABASES",
"DESCRIBE",
"REGEXP",
"SHOW",
"TABLES",
"USE",
],
Engine::Sqlite => &[
"ATTACH",
"AUTOINCREMENT",
"GLOB",
"PRAGMA",
"RETURNING",
"VACUUM",
],
}
}
const TABLE_CLAUSES: &[&str] = &[
"FROM", "JOIN", "INTO", "UPDATE", "TABLE", "TRUNCATE", "DESCRIBE",
];
const CLAUSE_WORDS: &[&str] = &[
"AND",
"AS",
"BY",
"CROSS",
"DELETE",
"EXCEPT",
"FROM",
"FULL",
"GROUP",
"HAVING",
"INNER",
"INSERT",
"INTERSECT",
"INTO",
"JOIN",
"LATERAL",
"LEFT",
"LIMIT",
"NATURAL",
"OFFSET",
"ON",
"OR",
"ORDER",
"OUTER",
"RETURNING",
"RIGHT",
"SELECT",
"SET",
"STRAIGHT_JOIN",
"TABLE",
"UNION",
"UPDATE",
"USING",
"VALUES",
"WHERE",
"WINDOW",
"WITH",
];
pub fn complete(
sql: &str,
cursor: usize,
schemas: &Schemas<'_>,
engine: Engine,
) -> Option<Completions> {
let cursor = floor_char_boundary(sql, cursor.min(sql.len()));
let start = statement_start(sql, cursor, engine);
let before = lex(&sql[start..cursor], start, engine)?;
let (prefix, replace, rest) = match before.last() {
Some(Token::Word {
text,
quoted: false,
range,
}) if range.end == cursor => (text.clone(), range.clone(), &before[..before.len() - 1]),
_ => (String::new(), cursor..cursor, &before[..]),
};
let qualifier = qualifier(rest, replace.start);
if prefix.is_empty() && qualifier.is_none() {
return None;
}
let end = statement_end(sql, cursor, engine);
let statement = lex(&sql[start..end], start, engine).unwrap_or_default();
let references = references(&statement, &replace);
let in_table_clause = qualifier.is_none() && expects_table(rest);
let lead = if in_table_clause {
SuggestionKind::Table
} else {
SuggestionKind::Column
};
let mut out = Collector::new(&prefix, engine, lead);
let mut missing = Vec::new();
for reference in &references {
if resolve(schemas, reference).is_none() {
for name in reference.database.iter().chain(reference.schema.iter()) {
if unfetched_database(schemas, name) {
push_missing(&mut missing, name.clone());
}
}
}
}
match qualifier {
Some(path) => qualified(&mut out, &mut missing, &path, &references, schemas),
None if in_table_clause => {
objects(&mut out, schemas.current, None);
schema_names(&mut out, schemas.current);
databases(&mut out, schemas);
keywords(&mut out);
if !prefix.is_empty() && unfetched_database(schemas, &prefix) {
push_missing(&mut missing, prefix.clone());
}
}
None => {
for reference in &references {
if let Some(resolved) = resolve(schemas, reference) {
columns_of(
&mut out,
resolved.catalog,
&resolved.object,
references.len() > 1,
resolved.database.as_deref(),
);
}
}
for alias in select_aliases(&statement, &replace) {
out.name(&alias, SuggestionKind::Column, "Alias".into());
}
keywords(&mut out);
objects(&mut out, schemas.current, None);
routines(&mut out, schemas.current);
if references.is_empty() {
all_columns(&mut out, schemas.current);
}
}
}
let items = out.finish();
if items.is_empty() && missing.is_empty() {
return None;
}
Some(Completions {
replace,
prefix,
items,
missing,
})
}
fn push_missing(missing: &mut Vec<String>, name: String) {
if !missing
.iter()
.any(|known| known.eq_ignore_ascii_case(&name))
{
missing.push(name);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Word {
text: String,
quoted: bool,
range: Range<usize>,
},
Dot(usize),
Comma,
Open,
Close,
Other,
}
impl Token {
fn keyword(&self) -> Option<String> {
match self {
Token::Word {
text,
quoted: false,
..
} => Some(text.to_ascii_uppercase()),
_ => None,
}
}
fn name(&self) -> Option<&str> {
match self {
Token::Word { text, .. } => Some(text),
_ => None,
}
}
}
fn lex(text: &str, base: usize, engine: Engine) -> Option<Vec<Token>> {
let bytes = text.as_bytes();
let mut tokens = Vec::new();
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
let next = bytes.get(index + 1).copied();
match byte {
b'-' if next == Some(b'-') => index = line_end(bytes, index)?,
b'#' if engine == Engine::MySql => index = line_end(bytes, index)?,
b'/' if next == Some(b'*') => {
index = text[index + 2..].find("*/").map(|at| index + 2 + at + 2)?;
}
b'\'' => {
let escapes = match engine {
Engine::MySql => true,
Engine::Postgres => {
index > 0
&& matches!(bytes[index - 1], b'E' | b'e')
&& (index < 2
|| !(bytes[index - 2].is_ascii_alphanumeric()
|| bytes[index - 2] == b'_'))
}
Engine::Sqlite => false,
};
index = closing(bytes, index, b'\'', escapes)?;
}
b'$' if dollar_tag(text, index).is_some() => {
let tag = dollar_tag(text, index).expect("checked just above");
let body = index + tag.len();
index = text[body..].find(&tag).map(|at| body + at + tag.len())?;
}
b'"' | b'`' => {
let end = closing(bytes, index, byte, false)?;
let quote = char::from(byte);
tokens.push(Token::Word {
text: text[index + 1..end - 1]
.replace(&format!("{quote}{quote}"), "e.to_string()),
quoted: true,
range: base + index..base + end,
});
index = end;
}
b'.' => {
tokens.push(Token::Dot(base + index));
index += 1;
}
b',' => {
tokens.push(Token::Comma);
index += 1;
}
b'(' => {
tokens.push(Token::Open);
index += 1;
}
b')' => {
tokens.push(Token::Close);
index += 1;
}
_ if is_word_start(text, index) => {
let end = word_end(text, index);
tokens.push(Token::Word {
text: text[index..end].to_string(),
quoted: false,
range: base + index..base + end,
});
index = end;
}
_ if byte.is_ascii_whitespace() => index += 1,
_ => {
tokens.push(Token::Other);
index += text[index..].chars().next().map_or(1, char::len_utf8);
}
}
}
Some(tokens)
}
fn line_end(bytes: &[u8], index: usize) -> Option<usize> {
bytes[index..]
.iter()
.position(|&byte| byte == b'\n')
.map(|at| index + at + 1)
}
fn closing(bytes: &[u8], index: usize, close: u8, escapes: bool) -> Option<usize> {
let mut at = index + 1;
while at < bytes.len() {
if escapes && bytes[at] == b'\\' {
at += 2;
continue;
}
if bytes[at] == close {
if bytes.get(at + 1) == Some(&close) {
at += 2;
continue;
}
return Some(at + 1);
}
at += 1;
}
None
}
fn dollar_tag(text: &str, index: usize) -> Option<String> {
let rest = &text[index + 1..];
let end = rest.find('$')?;
let tag = &rest[..end];
(tag.is_empty()
|| (!tag.starts_with(|c: char| c.is_ascii_digit())
&& tag.chars().all(|c| c.is_alphanumeric() || c == '_')))
.then(|| format!("${tag}$"))
}
fn is_word_char(character: char) -> bool {
character.is_alphanumeric() || character == '_' || character == '$'
}
fn is_word_start(text: &str, index: usize) -> bool {
text[index..]
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '_')
}
fn word_end(text: &str, index: usize) -> usize {
text[index..]
.char_indices()
.find(|&(_, c)| !is_word_char(c))
.map_or(text.len(), |(at, _)| index + at)
}
fn floor_char_boundary(text: &str, mut index: usize) -> usize {
while !text.is_char_boundary(index) {
index -= 1;
}
index
}
fn statement_start(sql: &str, cursor: usize, engine: Engine) -> usize {
super::statement::split(&sql[..cursor], engine)
.last()
.map_or(cursor, |last| {
if sql[last.end..cursor].contains(';') {
cursor
} else {
last.start
}
})
}
fn statement_end(sql: &str, cursor: usize, engine: Engine) -> usize {
super::statement::split(&sql[cursor..], engine)
.first()
.map_or(sql.len(), |first| {
if sql[cursor..cursor + first.start].contains(';') {
cursor
} else {
cursor + first.end
}
})
}
fn qualifier(tokens: &[Token], at: usize) -> Option<Vec<String>> {
let mut path = Vec::new();
let mut expect = at;
let mut rest = tokens;
while let [head @ .., Token::Word { text, range, .. }, Token::Dot(dot)] = rest {
if dot + 1 != expect || range.end != *dot {
break;
}
path.insert(0, text.clone());
expect = range.start;
rest = head;
}
(!path.is_empty()).then_some(path)
}
fn expects_table(tokens: &[Token]) -> bool {
let mut depth = 0i32;
for token in tokens.iter().rev() {
match token {
Token::Close => depth += 1,
Token::Open if depth == 0 => return false,
Token::Open => depth -= 1,
_ => {}
}
if let Some(word) = token.keyword() {
if TABLE_CLAUSES.contains(&word.as_str()) {
return depth == 0;
}
if CLAUSE_WORDS.contains(&word.as_str()) {
return false;
}
}
}
false
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Reference {
database: Option<String>,
schema: Option<String>,
name: String,
alias: Option<String>,
}
fn references(tokens: &[Token], typing: &Range<usize>) -> Vec<Reference> {
let mut found = Vec::new();
let mut index = 0;
while index < tokens.len() {
let in_list = matches!(
tokens[index].keyword().as_deref(),
Some("FROM" | "JOIN" | "UPDATE" | "INTO")
);
index += 1;
if !in_list {
continue;
}
while let Some((reference, next)) = reference_at(tokens, index, typing) {
found.push(reference);
index = next;
if matches!(tokens.get(index), Some(Token::Comma)) {
index += 1;
} else {
break;
}
}
}
found
}
fn reference_at(
tokens: &[Token],
mut index: usize,
typing: &Range<usize>,
) -> Option<(Reference, usize)> {
let mut path = Vec::new();
loop {
let Token::Word { text, range, .. } = tokens.get(index)? else {
return None;
};
if range == typing {
return None;
}
path.push(text.clone());
index += 1;
match tokens.get(index) {
Some(Token::Dot(_)) => index += 1,
_ => break,
}
}
let name = path.pop()?;
let schema = path.pop();
let database = path.pop();
let mut alias = None;
if tokens.get(index).and_then(Token::keyword).as_deref() == Some("AS") {
index += 1;
}
if let Some(token @ Token::Word { range, .. }) = tokens.get(index)
&& range != typing
&& !token
.keyword()
.is_some_and(|word| CLAUSE_WORDS.contains(&word.as_str()))
{
alias = token.name().map(str::to_string);
index += 1;
}
Some((
Reference {
database,
schema,
name,
alias,
},
index,
))
}
fn select_aliases(tokens: &[Token], typing: &Range<usize>) -> Vec<String> {
let mut aliases = Vec::new();
let mut depth = 0i32;
let mut index = 0;
while index < tokens.len() {
match &tokens[index] {
Token::Open => depth += 1,
Token::Close => depth = depth.saturating_sub(1),
token if depth == 0 && token.keyword().as_deref() == Some("SELECT") => {
index = select_list(tokens, index + 1, typing, &mut aliases);
continue;
}
_ => {}
}
index += 1;
}
aliases
}
fn select_list(
tokens: &[Token],
mut index: usize,
typing: &Range<usize>,
aliases: &mut Vec<String>,
) -> usize {
let mut item: Vec<&Token> = Vec::new();
let mut depth = 0i32;
loop {
let done = match tokens.get(index) {
None => true,
Some(Token::Open) => {
depth += 1;
false
}
Some(Token::Close) if depth == 0 => true,
Some(Token::Close) => {
depth -= 1;
false
}
Some(Token::Comma) if depth == 0 => {
item_alias(&item, typing)
.into_iter()
.for_each(|alias| aliases.push(alias));
item.clear();
index += 1;
continue;
}
Some(token)
if depth == 0
&& token.keyword().is_some_and(|word| {
word != "AS" && CLAUSE_WORDS.contains(&word.as_str())
}) =>
{
true
}
_ => false,
};
if done {
item_alias(&item, typing)
.into_iter()
.for_each(|alias| aliases.push(alias));
return index;
}
item.push(&tokens[index]);
index += 1;
}
}
fn item_alias(item: &[&Token], typing: &Range<usize>) -> Option<String> {
for (at, token) in item.iter().enumerate() {
if token.keyword().as_deref() == Some("AS") {
return match item.get(at + 1) {
Some(Token::Word { text, range, .. }) if !overlaps(range, typing) => {
Some((*text).clone())
}
_ => None,
};
}
}
let [.., before, last] = item else {
return None;
};
let Token::Word {
text,
quoted,
range,
..
} = last
else {
return None;
};
if !*quoted && KEYWORDS.contains(&text.to_ascii_uppercase().as_str()) {
return None;
}
let plain_word = matches!(before, Token::Word { quoted: true, .. })
|| before
.keyword()
.is_some_and(|word| !KEYWORDS.contains(&word.as_str()));
if !(matches!(before, Token::Close) || plain_word) || overlaps(range, typing) {
return None;
}
Some((*text).clone())
}
fn overlaps(range: &Range<usize>, typing: &Range<usize>) -> bool {
range.start < typing.end && typing.start < range.end
}
fn db_catalog<'a>(schemas: &'a Schemas<'a>, name: &str) -> Option<&'a Catalog> {
if schemas
.current_database
.is_some_and(|current| current.eq_ignore_ascii_case(name))
{
return Some(schemas.current);
}
schemas.others.get(&name.to_lowercase()).map(Arc::as_ref)
}
fn unfetched_database(schemas: &Schemas<'_>, name: &str) -> bool {
db_catalog(schemas, name).is_none()
&& schemas
.databases
.iter()
.any(|database| database.eq_ignore_ascii_case(name))
}
struct Resolved<'a> {
catalog: &'a Catalog,
object: CatalogEntry,
database: Option<String>,
}
fn resolve<'a>(schemas: &'a Schemas<'a>, reference: &Reference) -> Option<Resolved<'a>> {
if let Some(database) = &reference.database {
let catalog = db_catalog(schemas, database)?;
let object = find_object(catalog, reference.schema.as_deref(), &reference.name)?;
return Some(Resolved {
catalog,
object,
database: other_database(schemas, catalog, database),
});
}
if let Some(schema) = &reference.schema {
if let Some(object) = find_object(schemas.current, Some(schema), &reference.name) {
return Some(Resolved {
catalog: schemas.current,
object,
database: None,
});
}
if let Some(catalog) = db_catalog(schemas, schema) {
let object = find_object(catalog, None, &reference.name)?;
return Some(Resolved {
catalog,
object,
database: other_database(schemas, catalog, schema),
});
}
return None;
}
find_object(schemas.current, None, &reference.name).map(|object| Resolved {
catalog: schemas.current,
object,
database: None,
})
}
fn other_database(schemas: &Schemas<'_>, catalog: &Catalog, name: &str) -> Option<String> {
(!std::ptr::eq(catalog, schemas.current)).then(|| name.to_string())
}
fn find_object(catalog: &Catalog, schema: Option<&str>, name: &str) -> Option<CatalogEntry> {
let candidates = catalog.entries.iter().filter(|entry| {
matches!(entry.kind, CatalogKind::Table | CatalogKind::View)
&& entry.name.eq_ignore_ascii_case(name)
&& schema.is_none_or(|schema| {
entry
.object
.as_ref()
.and_then(|object| object.schema.as_deref())
.is_some_and(|own| own.eq_ignore_ascii_case(schema))
})
});
let mut fallback = None;
for entry in candidates {
if entry.name == name {
return Some(entry.clone());
}
fallback.get_or_insert_with(|| entry.clone());
}
fallback
}
fn qualified(
out: &mut Collector,
missing: &mut Vec<String>,
path: &[String],
references: &[Reference],
schemas: &Schemas<'_>,
) {
if let [name] = path {
let aliased = references.iter().find(|reference| {
reference
.alias
.as_ref()
.is_some_and(|alias| alias.eq_ignore_ascii_case(name))
});
let named = references
.iter()
.find(|reference| reference.name.eq_ignore_ascii_case(name));
if let Some(resolved) = aliased
.or(named)
.and_then(|reference| resolve(schemas, reference))
{
columns_of(
out,
resolved.catalog,
&resolved.object,
false,
resolved.database.as_deref(),
);
} else if let Some(object) = find_object(schemas.current, None, name) {
columns_of(out, schemas.current, &object, false, None);
}
objects(out, schemas.current, Some(name));
routines_in(out, schemas.current, name);
if let Some(catalog) = db_catalog(schemas, name) {
db_objects(out, name, catalog);
} else if unfetched_database(schemas, name) {
push_missing(missing, name.clone());
}
} else if let [schema, table] = path {
if let Some(object) = find_object(schemas.current, Some(schema), table) {
columns_of(out, schemas.current, &object, false, None);
}
if let Some(catalog) = db_catalog(schemas, schema) {
if let Some(object) = find_object(catalog, None, table) {
columns_of(
out,
catalog,
&object,
false,
other_database(schemas, catalog, schema).as_deref(),
);
}
} else if unfetched_database(schemas, schema) {
push_missing(missing, schema.clone());
}
} else if let [database, schema, table] = path {
if let Some(catalog) = db_catalog(schemas, database) {
if let Some(object) = find_object(catalog, Some(schema), table) {
columns_of(
out,
catalog,
&object,
false,
other_database(schemas, catalog, database).as_deref(),
);
}
} else if unfetched_database(schemas, database) {
push_missing(missing, database.clone());
}
}
}
fn columns_of(
out: &mut Collector,
catalog: &Catalog,
object: &CatalogEntry,
owned: bool,
database: Option<&str>,
) {
let Some(owner) = object.object.as_ref() else {
return;
};
for entry in &catalog.entries {
if entry.kind == CatalogKind::Column && entry.object.as_ref() == Some(owner) {
let mut detail = if owned {
format!("{} · {}", entry.detail, owner.name)
} else {
entry.detail.clone()
};
if let Some(database) = database {
detail.push_str(" · ");
detail.push_str(database);
}
out.name(&entry.name, SuggestionKind::Column, detail);
}
}
}
fn all_columns(out: &mut Collector, catalog: &Catalog) {
for entry in &catalog.entries {
if entry.kind == CatalogKind::Column && out.matches(&entry.name) {
let detail = entry
.object
.as_ref()
.map(|owner| format!("{} · {}", entry.detail, owner.name))
.unwrap_or_else(|| entry.detail.clone());
out.name(&entry.name, SuggestionKind::Column, detail);
}
}
}
fn objects(out: &mut Collector, catalog: &Catalog, schema: Option<&str>) {
for entry in &catalog.entries {
let kind = match entry.kind {
CatalogKind::Table => SuggestionKind::Table,
CatalogKind::View => SuggestionKind::View,
_ => continue,
};
if !out.matches(&entry.name) {
continue;
}
let own_schema = entry.object.as_ref().and_then(|o| o.schema.as_deref());
if let Some(schema) = schema
&& !own_schema.is_some_and(|own| own.eq_ignore_ascii_case(schema))
{
continue;
}
let detail = match (schema, own_schema) {
(None, Some(own)) => format!("{} · {own}", entry.kind.label()),
_ => entry.kind.label().to_string(),
};
out.name(&entry.name, kind, detail);
}
}
fn schema_names(out: &mut Collector, catalog: &Catalog) {
for entry in &catalog.entries {
if let Some(schema) = entry
.object
.as_ref()
.and_then(|object| object.schema.as_deref())
.or_else(|| entry.routine.as_ref().and_then(|r| r.schema.as_deref()))
{
out.name(schema, SuggestionKind::Schema, "Schema".into());
}
}
}
fn databases(out: &mut Collector, schemas: &Schemas<'_>) {
for database in schemas.databases {
out.name(database, SuggestionKind::Schema, "Database".into());
}
}
fn db_objects(out: &mut Collector, database: &str, catalog: &Catalog) {
for entry in &catalog.entries {
let kind = match entry.kind {
CatalogKind::Table => SuggestionKind::Table,
CatalogKind::View => SuggestionKind::View,
CatalogKind::Routine => SuggestionKind::Routine,
_ => continue,
};
if out.matches(&entry.name) {
out.name(
&entry.name,
kind,
format!("{} · {database}", entry.kind_label()),
);
}
}
}
fn routines(out: &mut Collector, catalog: &Catalog) {
for entry in &catalog.entries {
if entry.kind == CatalogKind::Routine {
out.name(
&entry.name,
SuggestionKind::Routine,
entry.kind_label().into(),
);
}
}
}
fn routines_in(out: &mut Collector, catalog: &Catalog, schema: &str) {
for entry in &catalog.entries {
if entry.kind == CatalogKind::Routine
&& entry
.routine
.as_ref()
.and_then(|routine| routine.schema.as_deref())
.is_some_and(|own| own.eq_ignore_ascii_case(schema))
{
out.name(
&entry.name,
SuggestionKind::Routine,
entry.kind_label().into(),
);
}
}
}
fn keywords(out: &mut Collector) {
let engine = out.engine;
for keyword in KEYWORDS.iter().chain(engine_keywords(engine)) {
out.keyword(keyword);
}
}
struct Collector {
prefix: String,
engine: Engine,
upper: bool,
lead: usize,
seen: HashSet<(String, usize)>,
groups: Vec<Vec<Suggestion>>,
}
impl Collector {
fn new(prefix: &str, engine: Engine, lead: SuggestionKind) -> Self {
Self {
prefix: prefix.to_lowercase(),
engine,
upper: prefix.is_empty() || prefix.chars().any(char::is_uppercase),
lead: group_of(lead),
seen: HashSet::new(),
groups: vec![Vec::new(); GROUPS],
}
}
fn matches(&self, label: &str) -> bool {
let mut rest = label.chars().flat_map(char::to_lowercase);
self.prefix.chars().all(|typed| rest.next() == Some(typed)) && rest.next().is_some()
}
fn push(&mut self, suggestion: Suggestion) {
let group = group_of(suggestion.kind);
if self.seen.insert((suggestion.label.clone(), group)) {
self.groups[group].push(suggestion);
}
}
fn name(&mut self, name: &str, kind: SuggestionKind, detail: String) {
if self.matches(name) {
self.push(Suggestion {
label: name.to_string(),
insert: quote(name, self.engine),
kind,
detail,
});
}
}
fn keyword(&mut self, keyword: &str) {
if self.matches(keyword) {
let insert = if self.upper {
keyword.to_string()
} else {
keyword.to_ascii_lowercase()
};
self.push(Suggestion {
label: insert.clone(),
insert,
kind: SuggestionKind::Keyword,
detail: "Keyword".into(),
});
}
}
fn finish(mut self) -> Vec<Suggestion> {
let columns = group_of(SuggestionKind::Column);
for (index, group) in self.groups.iter_mut().enumerate() {
if index != columns {
group.sort_by(|a, b| (a.label.len(), &a.label).cmp(&(b.label.len(), &b.label)));
}
}
let keywords = group_of(SuggestionKind::Keyword);
let mut order = vec![self.lead, keywords];
order.extend((0..GROUPS).filter(|group| *group != self.lead && *group != keywords));
order
.into_iter()
.flat_map(|group| std::mem::take(&mut self.groups[group]))
.take(MAX_SUGGESTIONS)
.collect()
}
}
const GROUPS: usize = 5;
fn group_of(kind: SuggestionKind) -> usize {
match kind {
SuggestionKind::Column => 0,
SuggestionKind::Table | SuggestionKind::View => 1,
SuggestionKind::Schema => 2,
SuggestionKind::Routine => 3,
SuggestionKind::Keyword => 4,
}
}
fn quote(name: &str, engine: Engine) -> String {
let plain = !name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !super::sql::is_reserved(name);
let folds = engine == Engine::Postgres && name.chars().any(|c| c.is_ascii_uppercase());
if plain && !folds {
return name.to_string();
}
match engine {
Engine::MySql => format!("`{}`", name.replace('`', "``")),
Engine::Postgres | Engine::Sqlite => format!("\"{}\"", name.replace('"', "\"\"")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::catalog::CatalogEntry;
use crate::db::{DatabaseObject, ObjectKind, StoredKind, StoredObject};
use std::collections::HashMap;
use std::sync::Arc;
fn table(schema: Option<&str>, name: &str) -> DatabaseObject {
DatabaseObject {
schema: schema.map(str::to_string),
name: name.into(),
kind: ObjectKind::Table,
}
}
fn catalog() -> Catalog {
let users = table(Some("public"), "users");
let orders = table(Some("public"), "orders");
let events = table(Some("audit"), "events");
let column = |owner: &DatabaseObject, name: &str, kind: &str| {
CatalogEntry::member(CatalogKind::Column, owner.clone(), name.into(), kind.into())
};
let entries = vec![
CatalogEntry::object(users.clone()),
CatalogEntry::object(orders.clone()),
CatalogEntry::object(events.clone()),
column(&users, "id", "integer"),
column(&users, "email", "text"),
column(&users, "Created", "timestamp"),
column(&orders, "id", "integer"),
column(&orders, "user_id", "integer"),
column(&orders, "order", "integer"),
column(&events, "kind", "text"),
CatalogEntry::routine(StoredObject {
schema: Some("public".into()),
name: "uuid_generate".into(),
arguments: Some("".into()),
kind: StoredKind::Function,
}),
];
Catalog {
total: entries.len(),
entries,
}
}
fn labels(sql: &str) -> Vec<String> {
labels_on(sql, Engine::Postgres)
}
fn labels_on(sql: &str, engine: Engine) -> Vec<String> {
let others = HashMap::new();
complete_on(sql, engine, &others, &[])
.map(|completions| completions.items.into_iter().map(|s| s.label).collect())
.unwrap_or_default()
}
fn complete_on(
sql: &str,
engine: Engine,
others: &HashMap<String, Arc<Catalog>>,
databases: &[String],
) -> Option<Completions> {
let catalog = catalog();
let schemas = Schemas {
current: &catalog,
current_database: Some("store"),
others,
databases,
};
let cursor = sql.find('|').expect("a caret marker");
let sql = sql.replacen('|', "", 1);
complete(&sql, cursor, &schemas, engine)
}
fn other_catalog() -> Catalog {
let products = table(None, "products");
let column = |owner: &DatabaseObject, name: &str, kind: &str| {
CatalogEntry::member(CatalogKind::Column, owner.clone(), name.into(), kind.into())
};
let entries = vec![
CatalogEntry::object(products.clone()),
column(&products, "id", "integer"),
column(&products, "name", "text"),
column(&products, "price", "decimal"),
];
Catalog {
total: entries.len(),
entries,
}
}
fn others_with(catalog: Catalog) -> HashMap<String, Arc<Catalog>> {
HashMap::from([("otherdb".to_string(), Arc::new(catalog))])
}
fn two_databases() -> Vec<String> {
vec!["store".to_string(), "otherdb".to_string()]
}
fn mysql_labels(sql: &str) -> Vec<String> {
let others = others_with(other_catalog());
complete_on(sql, Engine::MySql, &others, &two_databases())
.map(|completions| completions.items.into_iter().map(|s| s.label).collect())
.unwrap_or_default()
}
fn mysql_missing(sql: &str) -> Vec<String> {
complete_on(sql, Engine::MySql, &HashMap::new(), &two_databases())
.map(|completions| completions.missing)
.unwrap_or_default()
}
fn inserts(sql: &str) -> Vec<String> {
let others = HashMap::new();
complete_on(sql, Engine::Postgres, &others, &[])
.map(|completions| completions.items.into_iter().map(|s| s.insert).collect())
.unwrap_or_default()
}
#[test]
fn keywords_follow_the_case_being_typed() {
assert_eq!(labels("SEL|"), ["SELECT"]);
assert_eq!(labels("sel|"), ["select"]);
}
#[test]
fn a_finished_word_offers_nothing() {
assert!(labels("SELECT|").is_empty());
assert!(labels("SELECT |").is_empty());
}
#[test]
fn after_from_tables_come_first() {
let offered = labels("SELECT * FROM u|");
assert_eq!(offered.first().map(String::as_str), Some("users"));
assert!(!offered.contains(&"user_id".to_string()));
assert!(offered.contains(&"union".to_string()));
}
#[test]
fn columns_of_the_named_table_come_first() {
let offered = labels("SELECT e| FROM users");
assert_eq!(offered.first().map(String::as_str), Some("email"));
assert!(offered.contains(&"events".to_string()));
assert!(
offered.iter().position(|l| l == "email") < offered.iter().position(|l| l == "events")
);
}
#[test]
fn an_alias_qualifies_its_tables_columns() {
assert_eq!(
labels("SELECT * FROM users u JOIN orders o ON o.| = u.id"),
["id", "user_id", "order"]
);
assert_eq!(labels("SELECT u.e| FROM users AS u"), ["email"]);
}
#[test]
fn a_select_list_alias_is_offered_as_a_column() {
let offered = labels(
"SELECT group_concat(user_id) AS assigned_users FROM orders GROUP BY id HAVING assi|",
);
assert!(offered.contains(&"assigned_users".to_string()));
let offered = labels(
"SELECT group_concat(user_id) AS assigned_users FROM orders GROUP BY id HAVING a|",
);
assert!(
offered.iter().position(|l| l == "assigned_users")
< offered.iter().position(|l| l == "add")
);
}
#[test]
fn a_select_list_alias_without_as_is_offered() {
let offered = labels("SELECT count(*) total FROM orders HAVING tot|");
assert!(offered.contains(&"total".to_string()));
}
#[test]
fn no_alias_is_invented_for_plain_or_qualified_columns() {
let details = complete_on(
"SELECT DISTINCT users.email FROM users HAVING ema|",
Engine::Postgres,
&HashMap::new(),
&[],
)
.map(|completions| {
completions
.items
.into_iter()
.map(|s| (s.label, s.detail))
.collect::<Vec<_>>()
})
.unwrap_or_default();
let email = details
.iter()
.find(|(label, _)| label == "email")
.expect("the column is offered");
assert_eq!(email.1, "text");
}
#[test]
fn the_alias_being_typed_is_not_offered() {
assert!(!labels("SELECT id AS ident| FROM users").contains(&"ident".to_string()));
}
#[test]
fn a_table_name_qualifies_its_columns_without_an_alias() {
assert_eq!(
labels("SELECT users.| FROM users"),
["id", "email", "Created"]
);
}
#[test]
fn a_schema_qualifies_its_tables() {
assert_eq!(labels("SELECT * FROM audit.|"), ["events"]);
assert_eq!(labels("SELECT audit.events.| FROM audit.events"), ["kind"]);
}
#[test]
fn a_comma_separated_from_list_names_every_table() {
let offered = labels("SELECT k|, e FROM users, audit.events ev");
assert_eq!(offered.first().map(String::as_str), Some("kind"));
assert_eq!(labels("SELECT ev.| FROM users, audit.events ev"), ["kind"]);
}
#[test]
fn an_insert_column_list_offers_columns() {
let offered = labels("INSERT INTO orders (u|");
assert_eq!(offered.first().map(String::as_str), Some("user_id"));
}
#[test]
fn nothing_inside_a_string_or_comment() {
assert!(labels("SELECT 'us|").is_empty());
assert!(labels("SELECT 1 -- us|").is_empty());
assert!(labels("SELECT 1 /* us|").is_empty());
assert_eq!(labels("SELECT 'x' FROM us|"), ["users", "using"]);
}
#[test]
fn names_are_quoted_where_the_engine_needs_it() {
assert_eq!(inserts("SELECT C| FROM users")[0], "\"Created\"");
assert_eq!(inserts("SELECT o.o| FROM orders o"), ["\"order\""]);
assert_eq!(
labels_on("SELECT C| FROM users", Engine::MySql)[0],
"Created"
);
}
#[test]
fn the_statement_the_caret_is_in_names_the_tables() {
let offered = labels("SELECT * FROM orders; SELECT u| FROM users");
assert!(!offered.contains(&"user_id".to_string()));
assert!(labels("SELECT * FROM orders; SELECT u|").contains(&"user_id".to_string()));
}
#[test]
fn before_a_from_any_column_is_offered() {
assert!(labels("SELECT user|").contains(&"user_id".to_string()));
}
#[test]
fn the_replaced_range_is_the_word_being_typed() {
let catalog = catalog();
let others = HashMap::new();
let databases = Vec::new();
let schemas = Schemas {
current: &catalog,
current_database: Some("store"),
others: &others,
databases: &databases,
};
let completions =
complete("SELECT ema FROM users", 10, &schemas, Engine::Postgres).expect("completions");
assert_eq!(completions.replace, 7..10);
assert_eq!(completions.prefix, "ema");
let completions =
complete("SELECT u. FROM users u", 9, &schemas, Engine::Postgres).expect("completions");
assert_eq!(completions.replace, 9..9);
}
#[test]
fn engine_keywords_are_offered_on_their_engine_only() {
assert_eq!(labels_on("PRAG|", Engine::Sqlite), ["PRAGMA"]);
assert!(labels_on("PRAG|", Engine::Postgres).is_empty());
}
#[test]
fn a_database_qualifier_offers_its_tables() {
assert_eq!(mysql_labels("SELECT * FROM otherdb.|"), ["products"]);
}
#[test]
fn a_database_qualified_table_offers_its_columns() {
assert_eq!(
mysql_labels("SELECT otherdb.products.| FROM otherdb.products"),
["id", "name", "price"]
);
}
#[test]
fn an_alias_of_another_database_table_offers_its_columns() {
assert_eq!(
mysql_labels("SELECT p.| FROM otherdb.products p"),
["id", "name", "price"]
);
assert_eq!(
mysql_labels("SELECT p.id FROM otherdb.products AS p WHERE p.|"),
["id", "name", "price"]
);
}
#[test]
fn the_current_database_name_reuses_the_loaded_schema() {
assert_eq!(
mysql_labels("SELECT store.orders.| FROM store.orders"),
["id", "user_id", "order"]
);
assert_eq!(mysql_missing("SELECT store.|"), Vec::<String>::new());
let offered = mysql_labels("SELECT * FROM store.|");
assert!(offered.contains(&"users".to_string()));
assert!(offered.contains(&"orders".to_string()));
}
#[test]
fn an_unfetched_database_is_reported_missing() {
assert_eq!(mysql_missing("SELECT * FROM otherdb.|"), ["otherdb"]);
assert_eq!(
mysql_missing("SELECT otherdb.products.| FROM otherdb.products"),
["otherdb"]
);
assert_eq!(
mysql_missing("SELECT p.| FROM otherdb.products p"),
["otherdb"]
);
let others = others_with(other_catalog());
let missing = complete_on(
"SELECT * FROM otherdb.|",
Engine::MySql,
&others,
&two_databases(),
)
.map(|completions| completions.missing)
.unwrap_or_default();
assert!(missing.is_empty());
assert!(mysql_missing("SELECT u.| FROM users u").is_empty());
}
#[test]
fn naming_a_database_prefetches_its_schema() {
assert_eq!(mysql_missing("SELECT * FROM otherdb|"), ["otherdb"]);
assert!(mysql_missing("SELECT * FROM otherd|").is_empty());
}
#[test]
fn from_offers_database_names() {
let offered = mysql_labels("SELECT * FROM o|");
assert!(offered.contains(&"otherdb".to_string()));
assert!(offered.contains(&"orders".to_string()));
}
#[test]
fn other_database_columns_name_their_database() {
let others = others_with(other_catalog());
let details = complete_on(
"SELECT p.| FROM otherdb.products p",
Engine::MySql,
&others,
&two_databases(),
)
.map(|completions| {
completions
.items
.into_iter()
.map(|s| s.detail)
.collect::<Vec<_>>()
})
.unwrap_or_default();
assert_eq!(
details,
["integer · otherdb", "text · otherdb", "decimal · otherdb"]
);
}
}