use crate::api::QueryAliases;
use crate::lexer::lex;
use molframe_core::diagnostic::Diagnostic;
use std::collections::HashSet;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CompletionKind {
Keyword,
Column,
Macro,
Alias,
Value,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CompletionItem {
pub label: String,
pub kind: CompletionKind,
}
pub trait StructureValues {
fn chain_labels(&self) -> Box<dyn Iterator<Item = &str> + '_>;
fn residue_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(std::iter::empty())
}
fn atom_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
Box::new(std::iter::empty())
}
}
impl StructureValues for molframe_core::structure::Structure {
fn chain_labels(&self) -> Box<dyn Iterator<Item = &str> + '_> {
let mut seen = HashSet::new();
Box::new(
self.data()
.chains()
.flat_map(|chain| [chain.label(), chain.auth_label()])
.flatten()
.filter(move |label| seen.insert(*label)),
)
}
fn residue_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
let mut seen = HashSet::new();
Box::new(
self.data()
.residues()
.filter_map(molframe_core::structure::ResidueRef::name)
.filter(move |name| seen.insert(*name)),
)
}
fn atom_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
let mut seen = HashSet::new();
Box::new(
self.data()
.atoms()
.filter_map(molframe_core::structure::AtomRef::name)
.filter(move |name| seen.insert(*name)),
)
}
}
#[derive(Clone, Debug)]
pub struct CompletionResult {
pub replacement_start: usize,
pub replacement_end: usize,
pub items: Vec<CompletionItem>,
pub diagnostics: Vec<Diagnostic>,
}
const KEYWORDS: &[&str] = &[
"and",
"or",
"not",
"within",
"beyond",
"around",
"sphzone",
"sphlayer",
"isolayer",
"cyzone",
"cylayer",
"point",
"of",
"byres",
"same",
"as",
"group",
"connected",
"bonded",
"by",
"to",
];
const COLUMNS: &[&str] = &[
"index",
"bynum",
"id",
"resindex",
"chainindex",
"modelindex",
"chain",
"label_chain",
"auth_chain",
"resid",
"label_resid",
"auth_resid",
"resname",
"label_resname",
"auth_resname",
"name",
"label_name",
"auth_name",
"altloc",
"assembly",
"model",
"entity",
"entity_type",
"element",
"segid",
"icode",
"record_type",
"x",
"y",
"z",
"mass",
"charge",
"formalcharge",
"radius",
"bfactor",
"occupancy",
"plddt",
"pae",
];
const MACROS: &[&str] = &[
"protein",
"backbone",
"sidechain",
"nucleic",
"nucleicbackbone",
"nucleicbase",
"nucleicsugar",
"water",
"ion",
"lipid",
"saccharide",
"hetero",
"hydrogen",
"heavy",
"polymer",
"ligand",
"aromatic",
"polar_hydrogen",
"nonpolar_hydrogen",
"helix",
"strand",
"sheet",
"alpha_helix",
"helix_310",
"pi_helix",
"polyproline",
"bridge",
"turn",
"bend",
"coil",
"plddt_very_high",
"plddt_confident",
"plddt_low",
"plddt_very_low",
];
fn value_column_candidates<'a>(
column: &str,
values: &'a dyn StructureValues,
) -> Box<dyn Iterator<Item = &'a str> + 'a> {
match column {
"chain" | "label_chain" | "auth_chain" => values.chain_labels(),
"resname" | "label_resname" | "auth_resname" => values.residue_names(),
"name" | "label_name" | "auth_name" => values.atom_names(),
_ => Box::new(std::iter::empty()),
}
}
fn previous_word(before: &str) -> Option<&str> {
let trimmed = before.trim_end_matches(|character: char| {
character.is_whitespace() || matches!(character, '(' | ')' | ',')
});
let word_start = trimmed
.char_indices()
.rev()
.find(|(_, character)| character.is_whitespace() || matches!(character, '(' | ')' | ','))
.map_or(0, |(index, character)| index + character.len_utf8());
let word = &trimmed[word_start..];
(!word.is_empty()).then_some(word)
}
#[must_use]
pub fn complete(
source: &str,
cursor: usize,
aliases: &QueryAliases,
values: Option<&dyn StructureValues>,
) -> CompletionResult {
let mut diagnostics = Vec::new();
if cursor > source.len() || !source.is_char_boundary(cursor) {
diagnostics.push(crate::lexer::syntax(
crate::lexer::end_of(source),
"completion cursor must be a UTF-8 byte boundary",
));
let boundary = source
.char_indices()
.map(|(index, _)| index)
.take_while(|&index| index < cursor)
.last()
.into_iter()
.fold(0, |_, value| value);
return CompletionResult {
replacement_start: boundary,
replacement_end: boundary,
items: Vec::new(),
diagnostics,
};
}
let prefix = &source[..cursor];
let (start, fragment) = token_fragment(prefix);
let after_operator = prefix
.trim_end()
.chars()
.next_back()
.is_some_and(|character| matches!(character, '=' | '<' | '>'));
let dollar_start = prefix.rfind('$').filter(|&index| {
prefix[index + 1..]
.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_')
&& (index == 0
|| prefix[..index]
.chars()
.next_back()
.is_some_and(|character| {
character.is_whitespace() || matches!(character, '(' | ')' | ',')
}))
});
let fresh_start = prefix.trim().is_empty();
let mut items = Vec::new();
if let Some(dollar) = dollar_start {
let alias_fragment = &prefix[dollar + 1..];
for name in aliases.names() {
if name.starts_with(alias_fragment) {
items.push(CompletionItem {
label: name.to_owned(),
kind: CompletionKind::Alias,
});
}
}
} else if !after_operator {
let column = previous_word(&prefix[..start])
.and_then(|word| crate::ast::Column::from_name(&word.to_ascii_lowercase()));
if let (Some(column), Some(values)) = (column, values) {
let mut seen = HashSet::new();
for value in value_column_candidates(column_name_of(column), values) {
if value.starts_with(fragment) && seen.insert(value) {
items.push(CompletionItem {
label: value.to_owned(),
kind: CompletionKind::Value,
});
}
}
} else if !fragment.is_empty() || fresh_start {
for &(candidate, kind) in &[
(KEYWORDS, CompletionKind::Keyword),
(COLUMNS, CompletionKind::Column),
(MACROS, CompletionKind::Macro),
] {
for &label in candidate {
if label.starts_with(fragment) {
items.push(CompletionItem {
label: label.to_owned(),
kind,
});
}
}
}
}
}
if let Err(finding) = lex(prefix) {
diagnostics.push(finding);
}
CompletionResult {
replacement_start: start,
replacement_end: cursor,
items,
diagnostics,
}
}
fn column_name_of(column: crate::ast::Column) -> &'static str {
match column {
crate::ast::Column::Chain => "chain",
crate::ast::Column::LabelChain => "label_chain",
crate::ast::Column::AuthChain => "auth_chain",
crate::ast::Column::ResidueName => "resname",
crate::ast::Column::LabelResidueName => "label_resname",
crate::ast::Column::AuthResidueName => "auth_resname",
crate::ast::Column::AtomName => "name",
crate::ast::Column::LabelAtomName => "label_name",
crate::ast::Column::AuthAtomName => "auth_name",
_ => "",
}
}
fn token_fragment(source: &str) -> (usize, &str) {
let start = source
.char_indices()
.rev()
.find_map(|(index, character)| {
(character.is_whitespace() || matches!(character, '(' | ')' | ','))
.then_some(index + character.len_utf8())
})
.into_iter()
.fold(0, |_, value| value);
(start, &source[start..])
}
#[cfg(test)]
#[path = "completion_tests.rs"]
mod tests;