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 "assembly",
150 "model",
151 "entity",
152 "entity_type",
153 "element",
154 "segid",
155 "icode",
156 "record_type",
157 "x",
158 "y",
159 "z",
160 "mass",
161 "charge",
162 "formalcharge",
163 "radius",
164 "bfactor",
165 "occupancy",
166 "plddt",
167 "pae",
168];
169
170const MACROS: &[&str] = &[
171 "protein",
172 "backbone",
173 "sidechain",
174 "nucleic",
175 "nucleicbackbone",
176 "nucleicbase",
177 "nucleicsugar",
178 "water",
179 "ion",
180 "lipid",
181 "saccharide",
182 "hetero",
183 "hydrogen",
184 "heavy",
185 "polymer",
186 "ligand",
187 "aromatic",
188 "polar_hydrogen",
189 "nonpolar_hydrogen",
190 "helix",
191 "strand",
192 "sheet",
193 "alpha_helix",
194 "helix_310",
195 "pi_helix",
196 "polyproline",
197 "bridge",
198 "turn",
199 "bend",
200 "coil",
201];
202
203fn value_column_candidates<'a>(
206 column: &str,
207 values: &'a dyn StructureValues,
208) -> Box<dyn Iterator<Item = &'a str> + 'a> {
209 match column {
210 "chain" | "label_chain" | "auth_chain" => values.chain_labels(),
211 "resname" | "label_resname" | "auth_resname" => values.residue_names(),
212 "name" | "label_name" | "auth_name" => values.atom_names(),
213 _ => Box::new(std::iter::empty()),
214 }
215}
216
217fn previous_word(before: &str) -> Option<&str> {
222 let trimmed = before.trim_end_matches(|character: char| {
223 character.is_whitespace() || matches!(character, '(' | ')' | ',')
224 });
225 let word_start = trimmed
226 .char_indices()
227 .rev()
228 .find(|(_, character)| character.is_whitespace() || matches!(character, '(' | ')' | ','))
229 .map_or(0, |(index, character)| index + character.len_utf8());
230 let word = &trimmed[word_start..];
231 (!word.is_empty()).then_some(word)
232}
233
234#[must_use]
246pub fn complete(
247 source: &str,
248 cursor: usize,
249 aliases: &QueryAliases,
250 values: Option<&dyn StructureValues>,
251) -> CompletionResult {
252 let mut diagnostics = Vec::new();
253 if cursor > source.len() || !source.is_char_boundary(cursor) {
254 diagnostics.push(crate::lexer::syntax(
255 crate::lexer::end_of(source),
256 "completion cursor must be a UTF-8 byte boundary",
257 ));
258 let boundary = source
260 .char_indices()
261 .map(|(index, _)| index)
262 .take_while(|&index| index < cursor)
263 .last()
264 .into_iter()
265 .fold(0, |_, value| value);
266 return CompletionResult {
267 replacement_start: boundary,
268 replacement_end: boundary,
269 items: Vec::new(),
270 diagnostics,
271 };
272 }
273
274 let prefix = &source[..cursor];
275 let (start, fragment) = token_fragment(prefix);
276 let after_operator = prefix
277 .trim_end()
278 .chars()
279 .next_back()
280 .is_some_and(|character| matches!(character, '=' | '<' | '>'));
281 let dollar_start = prefix.rfind('$').filter(|&index| {
285 prefix[index + 1..]
286 .chars()
287 .all(|character| character.is_ascii_alphanumeric() || character == '_')
288 && (index == 0
289 || prefix[..index]
290 .chars()
291 .next_back()
292 .is_some_and(|character| {
293 character.is_whitespace() || matches!(character, '(' | ')' | ',')
294 }))
295 });
296 let fresh_start = prefix.trim().is_empty();
297 let mut items = Vec::new();
298 if let Some(dollar) = dollar_start {
299 let alias_fragment = &prefix[dollar + 1..];
300 for name in aliases.names() {
301 if name.starts_with(alias_fragment) {
302 items.push(CompletionItem {
303 label: name.to_owned(),
304 kind: CompletionKind::Alias,
305 });
306 }
307 }
308 } else if !after_operator {
309 let column = previous_word(&prefix[..start])
310 .and_then(|word| crate::ast::Column::from_name(&word.to_ascii_lowercase()));
311 if let (Some(column), Some(values)) = (column, values) {
312 let mut seen = HashSet::new();
313 for value in value_column_candidates(column_name_of(column), values) {
314 if value.starts_with(fragment) && seen.insert(value) {
315 items.push(CompletionItem {
316 label: value.to_owned(),
317 kind: CompletionKind::Value,
318 });
319 }
320 }
321 } else if !fragment.is_empty() || fresh_start {
322 for &(candidate, kind) in &[
323 (KEYWORDS, CompletionKind::Keyword),
324 (COLUMNS, CompletionKind::Column),
325 (MACROS, CompletionKind::Macro),
326 ] {
327 for &label in candidate {
328 if label.starts_with(fragment) {
329 items.push(CompletionItem {
330 label: label.to_owned(),
331 kind,
332 });
333 }
334 }
335 }
336 }
337 }
338 if let Err(finding) = lex(prefix) {
339 diagnostics.push(finding);
340 }
341 CompletionResult {
342 replacement_start: start,
343 replacement_end: cursor,
344 items,
345 diagnostics,
346 }
347}
348
349fn column_name_of(column: crate::ast::Column) -> &'static str {
354 match column {
355 crate::ast::Column::Chain => "chain",
356 crate::ast::Column::LabelChain => "label_chain",
357 crate::ast::Column::AuthChain => "auth_chain",
358 crate::ast::Column::ResidueName => "resname",
359 crate::ast::Column::LabelResidueName => "label_resname",
360 crate::ast::Column::AuthResidueName => "auth_resname",
361 crate::ast::Column::AtomName => "name",
362 crate::ast::Column::LabelAtomName => "label_name",
363 crate::ast::Column::AuthAtomName => "auth_name",
364 _ => "",
365 }
366}
367fn token_fragment(source: &str) -> (usize, &str) {
368 let start = source
369 .char_indices()
370 .rev()
371 .find_map(|(index, character)| {
372 (character.is_whitespace() || matches!(character, '(' | ')' | ','))
373 .then_some(index + character.len_utf8())
374 })
375 .into_iter()
376 .fold(0, |_, value| value);
377 (start, &source[start..])
378}
379
380#[cfg(test)]
381#[path = "completion_tests.rs"]
382mod tests;