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            .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    // Dollar-prefixed names are a distinct namespace. Restrict the check to
267    // the current token so an earlier dollar cannot change completion mode,
268    // and compare aliases without the sigil.
269    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
334/// The registry label of a parsed column.
335///
336/// `Column::from_name` already canonicalised the input, so matching the
337/// canonical spelling is exact.
338fn 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;