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(®istry));
50/// assert!(Arc::ptr_eq(&generator.registry, ®istry));
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}