Skip to main content

molframe_query/language/
completion.rs

1//! Completion for the selection language.
2//!
3//! This module is the single implementation of completion: candidates come
4//! from the language registry and the alias registry, and structure-derived
5//! values are lent through [`StructureValues`] rather than copied into a
6//! second vocabulary. Nothing here parses the language a second time.
7
8use crate::api::QueryAliases;
9use crate::lexer::lex;
10use molframe_core::diagnostic::Diagnostic;
11use std::collections::HashSet;
12
13/// The category of a completion item.
14#[derive(Clone, Copy, Debug, PartialEq, Eq)]
15pub enum CompletionKind {
16    /// A language keyword such as `protein` or `and`.
17    Keyword,
18    /// A structure-property column such as `chain` or `bfactor`.
19    Column,
20    /// A built-in structural expansion such as `within` or `byres`.
21    Macro,
22    /// A named query supplied through the alias registry.
23    Alias,
24    /// A structure-derived value for the preceding column, such as a chain
25    /// identifier after `chain`.
26    Value,
27}
28
29/// One supported completion candidate.
30#[derive(Clone, Debug, PartialEq, Eq)]
31pub struct CompletionItem {
32    /// Text inserted when this item is accepted.
33    pub label: String,
34    /// Candidate category used by interactive clients for presentation.
35    pub kind: CompletionKind,
36}
37
38/// Lent, structure-derived completion values.
39///
40/// Implementors expose identifiers from native indexes as borrowed strings;
41/// completion never builds a second vocabulary or copies the structure's
42/// columns. A value the structure does not carry is simply omitted.
43pub trait StructureValues {
44    /// Distinct chain identifiers, in native order. Both namespaces are
45    /// offered when a structure stores them separately.
46    fn chain_labels(&self) -> Box<dyn Iterator<Item = &str> + '_>;
47
48    /// Distinct residue names, in native order.
49    fn residue_names(&self) -> Box<dyn Iterator<Item = &str> + '_> {
50        Box::new(std::iter::empty())
51    }
52
53    /// Distinct atom names, in native order.
54    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/// Completion response with a byte-addressed replacement range.
93#[derive(Clone, Debug)]
94pub struct CompletionResult {
95    /// Start of the token to replace, in UTF-8 bytes.
96    pub replacement_start: usize,
97    /// End of the token to replace, in UTF-8 bytes.
98    pub replacement_end: usize,
99    /// Candidates in deterministic registry order.
100    pub items: Vec<CompletionItem>,
101    /// Diagnostics produced while lexing the prefix.
102    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
189/// Columns whose operands are identifiers a structure can lend, mapped to the
190/// [`StructureValues`] method that supplies them.
191fn 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
203/// The identifier immediately before the token being completed, if any.
204///
205/// Delimiters that [`token_fragment`] treats as boundaries are skipped, so
206/// `(chain ` still resolves to the `chain` column.
207fn 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/// Computes completion using a UTF-8 byte cursor.
221///
222/// `cursor` is a byte offset, not a character index. A cursor in the middle
223/// of a UTF-8 scalar is rejected rather than silently moving to a different
224/// replacement range. The returned range always points into `source`.
225///
226/// After a value column such as `chain`, `resname`, or `name`, candidates
227/// come exclusively from `values` when it is supplied; language keywords are
228/// never mixed with structure identifiers. `$` references are completed
229/// exclusively from `aliases`. At a fresh token after a finished term, no
230/// candidates are offered until the caller types a character.
231#[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        // Keep the response range valid even for an interior UTF-8 offset.
245        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    // Dollar-prefixed names are a distinct namespace. Restrict the check to
268    // the current token so an earlier dollar cannot change completion mode,
269    // and compare aliases without the sigil.
270    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
335/// The registry label of a parsed column.
336///
337/// `Column::from_name` already canonicalised the input, so matching the
338/// canonical spelling is exact.
339fn 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;