Skip to main content

lattice_config/
completion.rs

1//! [`OptionsGenerator`] — completion source for `:set <Tab>`.
2//!
3//! Walks the registry on each invocation. The matcher (fuzzy by
4//! default) and ranker live in `lattice-completion`; this generator
5//! just produces the candidate set.
6//!
7//! Behaviour:
8//!
9//! - **Bare prefix** (no `=`): emits one candidate per registered
10//!   option name plus every alternate name form the option's
11//!   [`crate::OptionType::name_forms`] declares (boolean's
12//!   `noNAME`).
13//! - **`name=` prefix**: looks up `name` in the registry and emits
14//!   `name=value` candidates for each entry the option's
15//!   [`crate::ErasedOption::enumerate_values`] returns.
16//!   Free-form values (integers, paths, free strings) return
17//!   `None` and the candidate set is empty.
18//!
19//! No knowledge of `OptionKind` — the generator asks the
20//! type-erased view, which delegates to the type's trait impl.
21//! Adding a new option-type variant means writing one
22//! [`crate::OptionType`] impl; completion picks it up
23//! automatically.
24
25use std::sync::Arc;
26
27use lattice_completion::candidate::{CandidateData, CandidateKind, RawCandidate};
28use lattice_completion::traits::{CandidateGenerator, GenerateContext};
29
30use crate::ConfigRegistry;
31
32/// Completion source for `:set` — option names, and `name=value` pairs
33/// for options whose type enumerates its values.
34///
35/// Stateless beyond the shared registry handle: every
36/// [`CandidateGenerator::generate`] call re-walks the registry, so
37/// options registered later (a plugin's, a renderer's) appear without
38/// rebuilding the generator. Candidates are unfiltered; the
39/// `lattice-completion` matcher narrows them against the prefix.
40///
41/// # Examples
42///
43/// ```
44/// use std::sync::Arc;
45/// use lattice_config::{ConfigRegistry, OptionsGenerator};
46///
47/// let registry = Arc::new(ConfigRegistry::new());
48/// registry.init_from_linkme();
49/// let generator = OptionsGenerator::new(Arc::clone(&registry));
50/// assert!(Arc::ptr_eq(&generator.registry, &registry));
51/// ```
52pub struct OptionsGenerator {
53    /// The registry walked on each `generate` call. Shared, not
54    /// snapshotted: registrations after construction are visible.
55    pub registry: Arc<ConfigRegistry>,
56}
57
58impl OptionsGenerator {
59    /// A generator over `registry`.
60    pub fn new(registry: Arc<ConfigRegistry>) -> Self {
61        Self { registry }
62    }
63}
64
65impl CandidateGenerator for OptionsGenerator {
66    fn generate(&self, ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
67        let mut out = Vec::new();
68        let prefix = ctx.prefix;
69        if let Some(eq) = prefix.find('=') {
70            // Value-completion mode. Look up the option to the
71            // left of `=`, emit one candidate per known value.
72            // Slice `3c.unify.option-doc-annotator`: emit the
73            // `CandidateData::OptionValue` variant so the
74            // `DocSnippetAnnotator` populates the marginalia
75            // column from the type's per-value doc.
76            let name = &prefix[..eq];
77            if let Some(spec) = self.registry.lookup(name)
78                && let Some(values) = spec.enumerate_values_with_docs()
79            {
80                for v in values {
81                    let text = format!("{}={}", spec.name(), v.form);
82                    out.push(RawCandidate {
83                        insert_text: None,
84                        text: text.clone(),
85                        display: text,
86                        kind: CandidateKind::Option,
87                        data: CandidateData::OptionValue {
88                            option_name: spec.name().to_string(),
89                            value: v.form.to_string(),
90                            doc: v.doc.to_string(),
91                        },
92                        source: None,
93                        accept_action: None,
94                        annotations: Vec::new(),
95                        display_spans: Vec::new(),
96                    });
97                }
98            }
99            return out;
100        }
101        // Bare prefix: enumerate every option name + its alternate
102        // forms (booleans add `noNAME`). The matcher fuzzy-filters.
103        // Slice `3c.unify.option-doc-annotator`: emit the
104        // `CandidateData::Option` variant so the
105        // `DocSnippetAnnotator` populates the marginalia column
106        // from `OptionDecl::DOC`.
107        for spec in self.registry.iter() {
108            // One candidate per accepted name form: the canonical
109            // name, the boolean `noNAME` negation, every alias
110            // (`cul` / `cursorline` for `current-line-highlight` —
111            // the canonical has no `s`, so `:set curs<Tab>` can only
112            // match through an alias), and the `noALIAS` negation of
113            // each alias for booleans. All carry the canonical `name`
114            // in `data` so accept + marginalia resolve through the
115            // same spec regardless of which form the user typed.
116            let mk = |text: String| RawCandidate {
117                text: text.clone(),
118                insert_text: None,
119                display: text,
120                kind: CandidateKind::Option,
121                data: CandidateData::Option {
122                    name: spec.name().to_string(),
123                    current_value: spec.get_formatted(),
124                    doc: spec.doc().to_string(),
125                },
126                source: None,
127                accept_action: None,
128                annotations: Vec::new(),
129                display_spans: Vec::new(),
130            };
131            out.push(mk(spec.name().to_string()));
132            for alt in spec.name_forms() {
133                out.push(mk(alt));
134            }
135            for alias in spec.aliases() {
136                out.push(mk(alias.to_string()));
137                if spec.is_bool() {
138                    out.push(mk(format!("no{alias}")));
139                }
140            }
141        }
142        out
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    #![allow(clippy::unwrap_used)]
149    use super::*;
150    use crate::option::Option;
151    use lattice_core::Document;
152    use lattice_grammar::CommandRegistry;
153
154    #[test]
155    fn bare_prefix_enumerates_option_names_and_bool_no_forms() {
156        let registry = Arc::new(ConfigRegistry::new());
157        registry.register(Option::<bool>::new("number", true, ""));
158        registry.register(Option::<i64>::new("tabstop", 8, ""));
159        let g = OptionsGenerator::new(registry);
160        let doc = Document::from_text("");
161        let buf = doc.buffer();
162        let cmd_reg = CommandRegistry::new();
163        let ctx = GenerateContext {
164            prefix: "",
165            buffer: buf,
166            registry: &cmd_reg,
167            case_sensitive: false,
168        };
169        let out = g.generate(&ctx);
170        let names: Vec<&str> = out.iter().map(|c| c.text.as_str()).collect();
171        assert!(names.contains(&"number"));
172        assert!(names.contains(&"nonumber"));
173        assert!(names.contains(&"tabstop"));
174        // tabstop is int, no `notabstop`.
175        assert!(!names.contains(&"notabstop"));
176    }
177
178    /// Regression guard (plugin-completion tie-together): a plugin registers an
179    /// option into the SHARED `ConfigRegistry` at runtime (PH7.10's
180    /// `register_with_typeid`); `:set <Tab>` must show it. The generator holds
181    /// an `Arc<ConfigRegistry>` and walks it fresh each invocation, so an option
182    /// added AFTER the generator is built still appears — no boot snapshot.
183    #[test]
184    fn set_completion_reads_the_registry_live_so_runtime_added_options_appear() {
185        let registry = Arc::new(ConfigRegistry::new());
186        registry.register(Option::<bool>::new("builtin-opt", true, ""));
187        let g = OptionsGenerator::new(registry.clone());
188
189        let names = |g: &OptionsGenerator| -> Vec<String> {
190            let doc = Document::from_text("");
191            let buf = doc.buffer();
192            let cmd_reg = CommandRegistry::new();
193            let ctx = GenerateContext {
194                prefix: "",
195                buffer: buf,
196                registry: &cmd_reg,
197                case_sensitive: false,
198            };
199            g.generate(&ctx).into_iter().map(|c| c.text).collect()
200        };
201
202        // Registered AFTER the generator was built — the "plugin loads late" case.
203        registry.register(Option::<i64>::new("plugin-added-opt", 4, ""));
204        let out = names(&g);
205        assert!(out.contains(&"builtin-opt".to_string()));
206        assert!(
207            out.contains(&"plugin-added-opt".to_string()),
208            "a runtime-registered option must appear in :set completion (live read)"
209        );
210    }
211
212    #[test]
213    fn bare_prefix_enumerates_aliases_and_their_no_forms() {
214        let registry = Arc::new(ConfigRegistry::new());
215        // Mirror `current-line-highlight` (aliases `cul`/`cursorline`):
216        // the canonical name has no `s`, so `:set curs<Tab>` is only
217        // matchable through the alias candidate.
218        registry.register(
219            Option::<bool>::builder("current-line-highlight", false, "")
220                .aliases(&["cul", "cursorline"])
221                .build(),
222        );
223        registry.register(
224            Option::<i64>::builder("tabstop", 8, "")
225                .aliases(&["ts"])
226                .build(),
227        );
228        let g = OptionsGenerator::new(registry);
229        let doc = Document::from_text("");
230        let buf = doc.buffer();
231        let cmd_reg = CommandRegistry::new();
232        let ctx = GenerateContext {
233            prefix: "",
234            buffer: buf,
235            registry: &cmd_reg,
236            case_sensitive: false,
237        };
238        let out = g.generate(&ctx);
239        let names: Vec<&str> = out.iter().map(|c| c.text.as_str()).collect();
240        // Canonical + every alias surface as name candidates.
241        assert!(names.contains(&"current-line-highlight"));
242        assert!(names.contains(&"cursorline"));
243        assert!(names.contains(&"cul"));
244        // Booleans carry the `noNAME` negation of canonical AND aliases.
245        assert!(names.contains(&"nocurrent-line-highlight"));
246        assert!(names.contains(&"nocursorline"));
247        assert!(names.contains(&"nocul"));
248        // A non-bool alias surfaces, but without a `no` form.
249        assert!(names.contains(&"ts"));
250        assert!(!names.contains(&"nots"));
251        // Every alias candidate resolves back to the canonical spec so
252        // accept + marginalia stay correct regardless of typed form.
253        for c in &out {
254            if c.text == "cursorline" || c.text == "cul" || c.text == "nocursorline" {
255                match &c.data {
256                    CandidateData::Option { name, .. } => {
257                        assert_eq!(name, "current-line-highlight");
258                    }
259                    other => panic!("expected Option data, got {other:?}"),
260                }
261            }
262        }
263    }
264
265    #[test]
266    fn eq_prefix_emits_value_candidates_for_bool() {
267        let registry = Arc::new(ConfigRegistry::new());
268        registry.register(Option::<bool>::new("number", true, ""));
269        let g = OptionsGenerator::new(registry);
270        let doc = Document::from_text("");
271        let buf = doc.buffer();
272        let cmd_reg = CommandRegistry::new();
273        let ctx = GenerateContext {
274            prefix: "number=",
275            buffer: buf,
276            registry: &cmd_reg,
277            case_sensitive: false,
278        };
279        let out = g.generate(&ctx);
280        let texts: Vec<&str> = out.iter().map(|c| c.text.as_str()).collect();
281        // Bool completion advertises only the canonical
282        // `true`/`false` forms. The parser still accepts
283        // `on`/`off`/`1`/`0`/`yes`/`no` for back-compat with
284        // hand-written config files.
285        assert_eq!(texts.len(), 2, "expected 2 candidates, got {texts:?}");
286        assert!(texts.contains(&"number=true"));
287        assert!(texts.contains(&"number=false"));
288        assert!(!texts.contains(&"number=on"));
289        assert!(!texts.contains(&"number=off"));
290    }
291
292    #[test]
293    fn eq_prefix_emits_no_values_for_int() {
294        let registry = Arc::new(ConfigRegistry::new());
295        registry.register(Option::<i64>::new("tabstop", 8, ""));
296        let g = OptionsGenerator::new(registry);
297        let doc = Document::from_text("");
298        let buf = doc.buffer();
299        let cmd_reg = CommandRegistry::new();
300        let ctx = GenerateContext {
301            prefix: "tabstop=",
302            buffer: buf,
303            registry: &cmd_reg,
304            case_sensitive: false,
305        };
306        let out = g.generate(&ctx);
307        assert!(out.is_empty());
308    }
309}