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    "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
207/// Columns whose operands are identifiers a structure can lend, mapped to the
208/// [`StructureValues`] method that supplies them.
209fn 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
221/// The identifier immediately before the token being completed, if any.
222///
223/// Delimiters that [`token_fragment`] treats as boundaries are skipped, so
224/// `(chain ` still resolves to the `chain` column.
225fn 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/// Computes completion using a UTF-8 byte cursor.
239///
240/// `cursor` is a byte offset, not a character index. A cursor in the middle
241/// of a UTF-8 scalar is rejected rather than silently moving to a different
242/// replacement range. The returned range always points into `source`.
243///
244/// After a value column such as `chain`, `resname`, or `name`, candidates
245/// come exclusively from `values` when it is supplied; language keywords are
246/// never mixed with structure identifiers. `$` references are completed
247/// exclusively from `aliases`. At a fresh token after a finished term, no
248/// candidates are offered until the caller types a character.
249#[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        // Keep the response range valid even for an interior UTF-8 offset.
263        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    // Dollar-prefixed names are a distinct namespace. Restrict the check to
286    // the current token so an earlier dollar cannot change completion mode,
287    // and compare aliases without the sigil.
288    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
353/// The registry label of a parsed column.
354///
355/// `Column::from_name` already canonicalised the input, so matching the
356/// canonical spelling is exact.
357fn 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;