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