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 .unwrap_or(0);
251 return CompletionResult {
252 replacement_start: boundary,
253 replacement_end: boundary,
254 items: Vec::new(),
255 diagnostics,
256 };
257 }
258
259 let prefix = &source[..cursor];
260 let (start, fragment) = token_fragment(prefix);
261 let after_operator = prefix
262 .trim_end()
263 .chars()
264 .next_back()
265 .is_some_and(|character| matches!(character, '=' | '<' | '>'));
266 let dollar_start = prefix.rfind('$').filter(|&index| {
270 prefix[index + 1..]
271 .chars()
272 .all(|character| character.is_ascii_alphanumeric() || character == '_')
273 && (index == 0
274 || prefix[..index]
275 .chars()
276 .next_back()
277 .is_some_and(|character| {
278 character.is_whitespace() || matches!(character, '(' | ')' | ',')
279 }))
280 });
281 let fresh_start = prefix.trim().is_empty();
282 let mut items = Vec::new();
283 if let Some(dollar) = dollar_start {
284 let alias_fragment = &prefix[dollar + 1..];
285 for name in aliases.names() {
286 if name.starts_with(alias_fragment) {
287 items.push(CompletionItem {
288 label: name.to_owned(),
289 kind: CompletionKind::Alias,
290 });
291 }
292 }
293 } else if !after_operator {
294 let column = previous_word(&prefix[..start])
295 .and_then(|word| crate::ast::Column::from_name(&word.to_ascii_lowercase()));
296 if let (Some(column), Some(values)) = (column, values) {
297 let mut seen = HashSet::new();
298 for value in value_column_candidates(column_name_of(column), values) {
299 if value.starts_with(fragment) && seen.insert(value) {
300 items.push(CompletionItem {
301 label: value.to_owned(),
302 kind: CompletionKind::Value,
303 });
304 }
305 }
306 } else if !fragment.is_empty() || fresh_start {
307 for &(candidate, kind) in &[
308 (KEYWORDS, CompletionKind::Keyword),
309 (COLUMNS, CompletionKind::Column),
310 (MACROS, CompletionKind::Macro),
311 ] {
312 for &label in candidate {
313 if label.starts_with(fragment) {
314 items.push(CompletionItem {
315 label: label.to_owned(),
316 kind,
317 });
318 }
319 }
320 }
321 }
322 }
323 if let Err(finding) = lex(prefix) {
324 diagnostics.push(finding);
325 }
326 CompletionResult {
327 replacement_start: start,
328 replacement_end: cursor,
329 items,
330 diagnostics,
331 }
332}
333
334fn column_name_of(column: crate::ast::Column) -> &'static str {
339 match column {
340 crate::ast::Column::Chain => "chain",
341 crate::ast::Column::LabelChain => "label_chain",
342 crate::ast::Column::AuthChain => "auth_chain",
343 crate::ast::Column::ResidueName => "resname",
344 crate::ast::Column::LabelResidueName => "label_resname",
345 crate::ast::Column::AuthResidueName => "auth_resname",
346 crate::ast::Column::AtomName => "name",
347 crate::ast::Column::LabelAtomName => "label_name",
348 crate::ast::Column::AuthAtomName => "auth_name",
349 _ => "",
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 .unwrap_or(0);
362 (start, &source[start..])
363}
364
365#[cfg(test)]
366#[path = "completion_tests.rs"]
367mod tests;