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 "plddt_very_high",
202 "plddt_confident",
203 "plddt_low",
204 "plddt_very_low",
205];
206
207fn value_column_candidates<'a>(
210 column: &str,
211 values: &'a dyn StructureValues,
212) -> Box<dyn Iterator<Item = &'a str> + 'a> {
213 match column {
214 "chain" | "label_chain" | "auth_chain" => values.chain_labels(),
215 "resname" | "label_resname" | "auth_resname" => values.residue_names(),
216 "name" | "label_name" | "auth_name" => values.atom_names(),
217 _ => Box::new(std::iter::empty()),
218 }
219}
220
221fn previous_word(before: &str) -> Option<&str> {
226 let trimmed = before.trim_end_matches(|character: char| {
227 character.is_whitespace() || matches!(character, '(' | ')' | ',')
228 });
229 let word_start = trimmed
230 .char_indices()
231 .rev()
232 .find(|(_, character)| character.is_whitespace() || matches!(character, '(' | ')' | ','))
233 .map_or(0, |(index, character)| index + character.len_utf8());
234 let word = &trimmed[word_start..];
235 (!word.is_empty()).then_some(word)
236}
237
238#[must_use]
250pub fn complete(
251 source: &str,
252 cursor: usize,
253 aliases: &QueryAliases,
254 values: Option<&dyn StructureValues>,
255) -> CompletionResult {
256 let mut diagnostics = Vec::new();
257 if cursor > source.len() || !source.is_char_boundary(cursor) {
258 diagnostics.push(crate::lexer::syntax(
259 crate::lexer::end_of(source),
260 "completion cursor must be a UTF-8 byte boundary",
261 ));
262 let boundary = source
264 .char_indices()
265 .map(|(index, _)| index)
266 .take_while(|&index| index < cursor)
267 .last()
268 .into_iter()
269 .fold(0, |_, value| value);
270 return CompletionResult {
271 replacement_start: boundary,
272 replacement_end: boundary,
273 items: Vec::new(),
274 diagnostics,
275 };
276 }
277
278 let prefix = &source[..cursor];
279 let (start, fragment) = token_fragment(prefix);
280 let after_operator = prefix
281 .trim_end()
282 .chars()
283 .next_back()
284 .is_some_and(|character| matches!(character, '=' | '<' | '>'));
285 let dollar_start = prefix.rfind('$').filter(|&index| {
289 prefix[index + 1..]
290 .chars()
291 .all(|character| character.is_ascii_alphanumeric() || character == '_')
292 && (index == 0
293 || prefix[..index]
294 .chars()
295 .next_back()
296 .is_some_and(|character| {
297 character.is_whitespace() || matches!(character, '(' | ')' | ',')
298 }))
299 });
300 let fresh_start = prefix.trim().is_empty();
301 let mut items = Vec::new();
302 if let Some(dollar) = dollar_start {
303 let alias_fragment = &prefix[dollar + 1..];
304 for name in aliases.names() {
305 if name.starts_with(alias_fragment) {
306 items.push(CompletionItem {
307 label: name.to_owned(),
308 kind: CompletionKind::Alias,
309 });
310 }
311 }
312 } else if !after_operator {
313 let column = previous_word(&prefix[..start])
314 .and_then(|word| crate::ast::Column::from_name(&word.to_ascii_lowercase()));
315 if let (Some(column), Some(values)) = (column, values) {
316 let mut seen = HashSet::new();
317 for value in value_column_candidates(column_name_of(column), values) {
318 if value.starts_with(fragment) && seen.insert(value) {
319 items.push(CompletionItem {
320 label: value.to_owned(),
321 kind: CompletionKind::Value,
322 });
323 }
324 }
325 } else if !fragment.is_empty() || fresh_start {
326 for &(candidate, kind) in &[
327 (KEYWORDS, CompletionKind::Keyword),
328 (COLUMNS, CompletionKind::Column),
329 (MACROS, CompletionKind::Macro),
330 ] {
331 for &label in candidate {
332 if label.starts_with(fragment) {
333 items.push(CompletionItem {
334 label: label.to_owned(),
335 kind,
336 });
337 }
338 }
339 }
340 }
341 }
342 if let Err(finding) = lex(prefix) {
343 diagnostics.push(finding);
344 }
345 CompletionResult {
346 replacement_start: start,
347 replacement_end: cursor,
348 items,
349 diagnostics,
350 }
351}
352
353fn column_name_of(column: crate::ast::Column) -> &'static str {
358 match column {
359 crate::ast::Column::Chain => "chain",
360 crate::ast::Column::LabelChain => "label_chain",
361 crate::ast::Column::AuthChain => "auth_chain",
362 crate::ast::Column::ResidueName => "resname",
363 crate::ast::Column::LabelResidueName => "label_resname",
364 crate::ast::Column::AuthResidueName => "auth_resname",
365 crate::ast::Column::AtomName => "name",
366 crate::ast::Column::LabelAtomName => "label_name",
367 crate::ast::Column::AuthAtomName => "auth_name",
368 _ => "",
369 }
370}
371fn token_fragment(source: &str) -> (usize, &str) {
372 let start = source
373 .char_indices()
374 .rev()
375 .find_map(|(index, character)| {
376 (character.is_whitespace() || matches!(character, '(' | ')' | ','))
377 .then_some(index + character.len_utf8())
378 })
379 .into_iter()
380 .fold(0, |_, value| value);
381 (start, &source[start..])
382}
383
384#[cfg(test)]
385#[path = "completion_tests.rs"]
386mod tests;