1use crate::api::QueryAliases;
9use crate::lexer::lex;
10use molframe_core::diagnostic::Diagnostic;
11use std::collections::HashSet;
12
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CompletionKind {
16 Keyword,
18 Column,
20 Macro,
22 Alias,
24 Value,
27}
28
29#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct CompletionItem {
32 pub label: String,
34 pub kind: CompletionKind,
36}
37
38pub trait StructureValues {
44 fn chain_labels(&self) -> Box<dyn Iterator<Item = &str> + '_>;
47
48 fn residue_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
50 Box::new(std::iter::empty())
51 }
52
53 fn atom_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
55 Box::new(std::iter::empty())
56 }
57}
58
59impl StructureValues for molframe_core::structure::Structure {
60 fn chain_labels(&self) -> Box<dyn Iterator<Item = &str> + '_> {
61 let mut seen = HashSet::new();
62 Box::new(
63 self.data()
64 .chains()
65 .flat_map(|chain| [chain.label(), chain.auth_label()])
66 .flatten()
67 .filter(move |label| seen.insert(*label)),
68 )
69 }
70
71 fn residue_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
72 let mut seen = HashSet::new();
73 Box::new(
74 self.data()
75 .residues()
76 .filter_map(molframe_core::structure::ResidueRef::name)
77 .filter(move |name| seen.insert(*name)),
78 )
79 }
80
81 fn atom_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
82 let mut seen = HashSet::new();
83 Box::new(
84 self.data()
85 .atoms()
86 .filter_map(molframe_core::structure::AtomRef::name)
87 .filter(move |name| seen.insert(*name)),
88 )
89 }
90}
91
92#[derive(Clone, Debug)]
94pub struct CompletionResult {
95 pub replacement_start: usize,
97 pub replacement_end: usize,
99 pub items: Vec<CompletionItem>,
101 pub diagnostics: Vec<Diagnostic>,
103}
104
105const KEYWORDS: &[&str] = &[
106 "and",
107 "or",
108 "not",
109 "within",
110 "beyond",
111 "around",
112 "sphzone",
113 "sphlayer",
114 "isolayer",
115 "cyzone",
116 "cylayer",
117 "point",
118 "of",
119 "byres",
120 "same",
121 "as",
122 "group",
123 "connected",
124 "bonded",
125 "by",
126 "to",
127];
128
129const COLUMNS: &[&str] = &[
130 "index",
131 "bynum",
132 "id",
133 "resindex",
134 "chainindex",
135 "modelindex",
136 "chain",
137 "label_chain",
138 "auth_chain",
139 "resid",
140 "label_resid",
141 "auth_resid",
142 "resname",
143 "label_resname",
144 "auth_resname",
145 "name",
146 "label_name",
147 "auth_name",
148 "altloc",
149 "model",
150 "entity",
151 "entity_type",
152 "element",
153 "segid",
154 "icode",
155 "record_type",
156 "x",
157 "y",
158 "z",
159 "mass",
160 "charge",
161 "formalcharge",
162 "radius",
163 "bfactor",
164 "occupancy",
165 "plddt",
166 "pae",
167];
168
169const MACROS: &[&str] = &[
170 "protein",
171 "backbone",
172 "sidechain",
173 "nucleic",
174 "nucleicbackbone",
175 "nucleicbase",
176 "nucleicsugar",
177 "water",
178 "ion",
179 "lipid",
180 "saccharide",
181 "hetero",
182 "hydrogen",
183 "heavy",
184 "polymer",
185 "ligand",
186 "aromatic",
187];
188
189fn value_column_candidates<'a>(
192 column: &str,
193 values: &'a dyn StructureValues,
194) -> Box<dyn Iterator<Item = &'a str> + 'a> {
195 match column {
196 "chain" | "label_chain" | "auth_chain" => values.chain_labels(),
197 "resname" | "label_resname" | "auth_resname" => values.residue_names(),
198 "name" | "label_name" | "auth_name" => values.atom_names(),
199 _ => Box::new(std::iter::empty()),
200 }
201}
202
203fn previous_word(before: &str) -> Option<&str> {
208 let trimmed = before.trim_end_matches(|character: char| {
209 character.is_whitespace() || matches!(character, '(' | ')' | ',')
210 });
211 let word_start = trimmed
212 .char_indices()
213 .rev()
214 .find(|(_, character)| character.is_whitespace() || matches!(character, '(' | ')' | ','))
215 .map_or(0, |(index, character)| index + character.len_utf8());
216 let word = &trimmed[word_start..];
217 (!word.is_empty()).then_some(word)
218}
219
220#[must_use]
232pub fn complete(
233 source: &str,
234 cursor: usize,
235 aliases: &QueryAliases,
236 values: Option<&dyn StructureValues>,
237) -> CompletionResult {
238 let mut diagnostics = Vec::new();
239 if cursor > source.len() || !source.is_char_boundary(cursor) {
240 diagnostics.push(crate::lexer::syntax(
241 crate::lexer::end_of(source),
242 "completion cursor must be a UTF-8 byte boundary",
243 ));
244 let boundary = source
246 .char_indices()
247 .map(|(index, _)| index)
248 .take_while(|&index| index < cursor)
249 .last()
250 .into_iter()
251 .fold(0, |_, value| value);
252 return CompletionResult {
253 replacement_start: boundary,
254 replacement_end: boundary,
255 items: Vec::new(),
256 diagnostics,
257 };
258 }
259
260 let prefix = &source[..cursor];
261 let (start, fragment) = token_fragment(prefix);
262 let after_operator = prefix
263 .trim_end()
264 .chars()
265 .next_back()
266 .is_some_and(|character| matches!(character, '=' | '<' | '>'));
267 let dollar_start = prefix.rfind('$').filter(|&index| {
271 prefix[index + 1..]
272 .chars()
273 .all(|character| character.is_ascii_alphanumeric() || character == '_')
274 && (index == 0
275 || prefix[..index]
276 .chars()
277 .next_back()
278 .is_some_and(|character| {
279 character.is_whitespace() || matches!(character, '(' | ')' | ',')
280 }))
281 });
282 let fresh_start = prefix.trim().is_empty();
283 let mut items = Vec::new();
284 if let Some(dollar) = dollar_start {
285 let alias_fragment = &prefix[dollar + 1..];
286 for name in aliases.names() {
287 if name.starts_with(alias_fragment) {
288 items.push(CompletionItem {
289 label: name.to_owned(),
290 kind: CompletionKind::Alias,
291 });
292 }
293 }
294 } else if !after_operator {
295 let column = previous_word(&prefix[..start])
296 .and_then(|word| crate::ast::Column::from_name(&word.to_ascii_lowercase()));
297 if let (Some(column), Some(values)) = (column, values) {
298 let mut seen = HashSet::new();
299 for value in value_column_candidates(column_name_of(column), values) {
300 if value.starts_with(fragment) && seen.insert(value) {
301 items.push(CompletionItem {
302 label: value.to_owned(),
303 kind: CompletionKind::Value,
304 });
305 }
306 }
307 } else if !fragment.is_empty() || fresh_start {
308 for &(candidate, kind) in &[
309 (KEYWORDS, CompletionKind::Keyword),
310 (COLUMNS, CompletionKind::Column),
311 (MACROS, CompletionKind::Macro),
312 ] {
313 for &label in candidate {
314 if label.starts_with(fragment) {
315 items.push(CompletionItem {
316 label: label.to_owned(),
317 kind,
318 });
319 }
320 }
321 }
322 }
323 }
324 if let Err(finding) = lex(prefix) {
325 diagnostics.push(finding);
326 }
327 CompletionResult {
328 replacement_start: start,
329 replacement_end: cursor,
330 items,
331 diagnostics,
332 }
333}
334
335fn column_name_of(column: crate::ast::Column) -> &'static str {
340 match column {
341 crate::ast::Column::Chain => "chain",
342 crate::ast::Column::LabelChain => "label_chain",
343 crate::ast::Column::AuthChain => "auth_chain",
344 crate::ast::Column::ResidueName => "resname",
345 crate::ast::Column::LabelResidueName => "label_resname",
346 crate::ast::Column::AuthResidueName => "auth_resname",
347 crate::ast::Column::AtomName => "name",
348 crate::ast::Column::LabelAtomName => "label_name",
349 crate::ast::Column::AuthAtomName => "auth_name",
350 _ => "",
351 }
352}
353fn token_fragment(source: &str) -> (usize, &str) {
354 let start = source
355 .char_indices()
356 .rev()
357 .find_map(|(index, character)| {
358 (character.is_whitespace() || matches!(character, '(' | ')' | ','))
359 .then_some(index + character.len_utf8())
360 })
361 .into_iter()
362 .fold(0, |_, value| value);
363 (start, &source[start..])
364}
365
366#[cfg(test)]
367#[path = "completion_tests.rs"]
368mod tests;