Skip to main content

lattice_host/
host_generators.rs

1//! App-state-dependent completion generators.
2//!
3//! `lattice-completion`'s built-in generators (`gen:commands`,
4//! `gen:files`) and `lattice-config`'s `gen:options` cover the
5//! state-free / config-tier sources. `gen:help-topics` lives in
6//! `lattice-help` next to the topic registry. Everything else --
7//! sources that need App-level handles (mode registry, event
8//! descriptors, LSP supervisor) -- lives here so the cmdline can
9//! offer `<Tab>` candidates for every described command in
10//! `lattice_grammar::ex_commands`.
11//!
12//! Each generator captures the minimal `Arc<...>` slice of state
13//! it needs and is registered in `App::new` next to the existing
14//! `gen:options` registration. Names are stable -- they appear as
15//! string literals in `ArgSpec::completion` -- so adding a new
16//! generator means registering it here and pointing the relevant
17//! arg schemas at it.
18
19use std::sync::Weak;
20
21use lattice_completion::candidate::{CandidateData, CandidateKind, RawCandidate};
22use lattice_completion::traits::{CandidateGenerator, GenerateContext};
23use lattice_picker::{
24    PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
25    RoutingPayload, SourceResult,
26};
27use lattice_theme::ThemeRegistryHandle;
28
29/// `gen:modes` -- one candidate per registered mode. Walks
30/// `ModeRegistry::iter_meta` so the candidate set follows the
31/// runtime registry (built-in foundation modes plus any feature-
32/// crate `register_*_modes` additions).
33///
34/// Holds a [`Weak`] to the registry so the generator doesn't keep
35/// the Arc alive past the App's lifetime, and -- the operational
36/// reason -- so `Arc::get_mut(&mut app.mode_registry)` still
37/// succeeds in tests that need to register a test-only mode
38/// post-boot. Upgrade-on-demand: a dropped registry yields an
39/// empty candidate set (no panic).
40pub struct ModesGenerator {
41    pub registry: Weak<arc_swap::ArcSwap<lattice_mode::ModeRegistry>>,
42}
43
44impl CandidateGenerator for ModesGenerator {
45    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
46        let Some(registry) = self.registry.upgrade() else {
47            return Vec::new();
48        };
49        let registry = registry.load();
50        let mut out: Vec<RawCandidate> = registry
51            .iter_meta()
52            .map(|(id, _kind)| RawCandidate {
53                insert_text: None,
54                text: id.as_str().to_string(),
55                display: id.as_str().to_string(),
56                kind: CandidateKind::Plain,
57                data: CandidateData::Plain,
58                source: None,
59                accept_action: None,
60                annotations: Vec::new(),
61                display_spans: Vec::new(),
62            })
63            .collect();
64        out.sort_by(|a, b| a.text.cmp(&b.text));
65        out
66    }
67}
68
69/// `gen:events` -- one candidate per registered event, from the unified view
70/// (`event_registry::all_events`): the compile-time `EVENT_DESCRIPTORS` linkme
71/// slice (built-ins) PLUS the runtime registry (plugin-defined events, PH7.8b),
72/// so a plugin's custom events complete here the moment they register.
73pub struct EventsGenerator;
74
75impl CandidateGenerator for EventsGenerator {
76    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
77        let mut out: Vec<RawCandidate> = lattice_protocol::event_registry::all_events()
78            .into_iter()
79            .map(|d| RawCandidate {
80                insert_text: None,
81                text: d.name.clone(),
82                display: d.name.clone(),
83                kind: CandidateKind::Plain,
84                data: CandidateData::Plain,
85                source: None,
86                accept_action: None,
87                annotations: Vec::new(),
88                display_spans: Vec::new(),
89            })
90            .collect();
91        out.sort_by(|a, b| a.text.cmp(&b.text));
92        out
93    }
94}
95
96/// `gen:elements` -- one candidate per registered theme element / face.
97/// Walks [`ThemeRegistry::element_names`] so the candidate set follows the
98/// live registry (core elements plus any mode/plugin-contributed ones).
99/// Drives `:describe-element <Tab>` / `:describe-face <Tab>`.
100///
101/// Holds a strong [`ThemeRegistryHandle`] clone, mirroring
102/// [`ThemePickerSource`] (the theme registry is a host ServiceRegistry
103/// service that renderers read; nothing takes `Arc::get_mut` of it
104/// post-boot, so the [`Weak`] discipline the mode-registry generators use
105/// doesn't apply here).
106pub struct ElementsGenerator {
107    pub registry: ThemeRegistryHandle,
108}
109
110impl CandidateGenerator for ElementsGenerator {
111    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
112        // `element_names()` is already sorted.
113        self.registry
114            .element_names()
115            .into_iter()
116            .map(|name| RawCandidate {
117                insert_text: None,
118                text: name.clone(),
119                display: name,
120                kind: CandidateKind::Plain,
121                data: CandidateData::Plain,
122                source: None,
123                accept_action: None,
124                annotations: Vec::new(),
125                display_spans: Vec::new(),
126            })
127            .collect()
128    }
129}
130
131/// `gen:log-levels` -- the five canonical log levels accepted by
132/// `:lsp-log-level`. Returned in severity order so the popup reads
133/// the same way the level enum does.
134pub struct LogLevelsGenerator;
135
136impl CandidateGenerator for LogLevelsGenerator {
137    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
138        ["error", "warn", "info", "debug", "trace"]
139            .iter()
140            .map(|name| RawCandidate {
141                insert_text: None,
142                text: (*name).to_string(),
143                display: (*name).to_string(),
144                kind: CandidateKind::Plain,
145                data: CandidateData::Plain,
146                source: None,
147                accept_action: None,
148                annotations: Vec::new(),
149                display_spans: Vec::new(),
150            })
151            .collect()
152    }
153}
154
155/// `gen:picker-sources` -- one candidate per source id registered
156/// with the host's [`PickerRegistry`](lattice_picker::PickerRegistry).
157/// Drives `:picker <Tab>` completion; the registry's contents
158/// dictate the candidate set, so feature crates that register new
159/// picker sources automatically surface in the popup.
160///
161/// Holds a [`Weak`] (mirror of [`ModesGenerator`]) so the App can
162/// still take ownership of the registry on shutdown / replacement;
163/// dropped-registry yields an empty candidate set rather than a
164/// panic.
165pub struct PickerSourcesGenerator {
166    pub registry: Weak<arc_swap::ArcSwap<lattice_picker::PickerRegistry>>,
167}
168
169impl CandidateGenerator for PickerSourcesGenerator {
170    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
171        let Some(registry) = self.registry.upgrade() else {
172            return Vec::new();
173        };
174        // Wait-free snapshot of the current registry (a plugin load may have
175        // RCU-swapped a fresh one in). `PickerRegistry::iter` is id-sorted
176        // already; mirror its order so popup ordering stays stable across runs.
177        let registry = registry.load();
178        registry
179            .iter()
180            .map(|(id, _spec)| RawCandidate {
181                insert_text: None,
182                text: id.to_string(),
183                display: id.to_string(),
184                kind: CandidateKind::Plain,
185                data: CandidateData::Plain,
186                source: None,
187                accept_action: None,
188                annotations: Vec::new(),
189                display_spans: Vec::new(),
190            })
191            .collect()
192    }
193}
194
195/// `gen:lsp-servers` -- one candidate per currently running LSP
196/// server id. Reads through the supervisor's wait-free snapshot
197/// (`ArcSwap`-backed) so completion never blocks on the supervisor
198/// task. Dedup-by-id across multi-workspace runs: the same server
199/// id attached to two different roots collapses to one candidate.
200pub struct LspServersGenerator {
201    pub lsp: lattice_lsp::LspSupervisorHandle,
202}
203
204impl CandidateGenerator for LspServersGenerator {
205    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
206        let mut ids: Vec<String> = self
207            .lsp
208            .running_actors()
209            .into_iter()
210            .map(|((_root, id), _handle)| id)
211            .collect();
212        ids.sort();
213        ids.dedup();
214        ids.into_iter()
215            .map(|id| RawCandidate {
216                insert_text: None,
217                text: id.clone(),
218                display: id,
219                kind: CandidateKind::Plain,
220                data: CandidateData::Plain,
221                source: None,
222                accept_action: None,
223                annotations: Vec::new(),
224                display_spans: Vec::new(),
225            })
226            .collect()
227    }
228}
229
230/// `gen:customize` -- candidates for `:customize <name>`. Merges
231/// two sources:
232///
233/// - Every group declared via the `groups!` macro
234///   (`GROUP_DECLS` distributed slice) -- bare names like
235///   `editor`, `display`, `lsp`.
236/// - Every registered mode -- names ending in `-mode` like
237///   `lsp-completion-mode`, `text-mode`.
238/// - Every plugin option namespace in the live [`ConfigRegistry`]
239///   -- `treesitter-context`, and whatever else is loaded.
240///
241/// The `:customize` parser routes on the `-mode` suffix, so a
242/// single candidate set covers both forms. Same [`Weak`] discipline
243/// as [`ModesGenerator`].
244///
245/// The third source exists because the first two are COMPILE-time
246/// slices. A plugin registers its options at runtime, so a
247/// decl-only candidate set silently omits every plugin group --
248/// `:customize <plugin>` works but is never offered, which reads as
249/// "the feature isn't there". The config handle is a strong `Arc`
250/// (matching `gen:options`, which holds the same registry) because
251/// the registry outlives the completion registry rather than the
252/// other way round.
253pub struct CustomizeNamesGenerator {
254    pub registry: Weak<arc_swap::ArcSwap<lattice_mode::ModeRegistry>>,
255    pub config: std::sync::Arc<lattice_config::ConfigRegistry>,
256}
257
258impl CandidateGenerator for CustomizeNamesGenerator {
259    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
260        let mut out: Vec<RawCandidate> = Vec::new();
261        for meta in lattice_config::group::GROUP_DECLS.iter() {
262            out.push(RawCandidate {
263                insert_text: None,
264                text: meta.name.to_string(),
265                display: meta.name.to_string(),
266                kind: CandidateKind::Plain,
267                data: CandidateData::Plain,
268                source: None,
269                accept_action: None,
270                annotations: Vec::new(),
271                display_spans: Vec::new(),
272            });
273        }
274        for ns in lattice_config::plugin_option_groups(&self.config).keys() {
275            out.push(RawCandidate {
276                insert_text: None,
277                text: ns.clone(),
278                display: ns.clone(),
279                kind: CandidateKind::Plain,
280                data: CandidateData::Plain,
281                source: None,
282                accept_action: None,
283                annotations: Vec::new(),
284                display_spans: Vec::new(),
285            });
286        }
287        if let Some(registry) = self.registry.upgrade() {
288            let registry = registry.load();
289            for (id, _kind) in registry.iter_meta() {
290                out.push(RawCandidate {
291                    insert_text: None,
292                    text: id.as_str().to_string(),
293                    display: id.as_str().to_string(),
294                    kind: CandidateKind::Plain,
295                    data: CandidateData::Plain,
296                    source: None,
297                    accept_action: None,
298                    annotations: Vec::new(),
299                    display_spans: Vec::new(),
300                });
301            }
302        }
303        out.sort_by(|a, b| a.text.cmp(&b.text));
304        out.dedup_by(|a, b| a.text == b.text);
305        out
306    }
307}
308
309/// T.12a: `:colorscheme` (no arg) — the live-preview theme picker.
310/// A trait-driven [`PickerSourceGenerator`] holding a clone of the
311/// [`ThemeRegistryHandle`] so it can enumerate registered theme names
312/// at `init` time. Arrowing through candidates LIVE-PREVIEWS each
313/// theme (the host applies the
314/// [`PickerPreviewOutcome::Colorscheme`](lattice_picker::PickerPreviewOutcome::Colorscheme)
315/// the [`Self::preview`] hook returns); `<Esc>` restores the theme
316/// active when the picker opened; `<CR>` keeps the highlighted theme.
317///
318/// Mode-owned-surface note: the host owns the theme subsystem (the
319/// `ThemeRegistry` is a host ServiceRegistry service the renderers
320/// read), so this source + its handler body both live host-side — no
321/// half-migration.
322pub struct ThemePickerSource {
323    spec: PickerSourceSpec,
324    registry: ThemeRegistryHandle,
325}
326
327impl ThemePickerSource {
328    pub fn new(registry: ThemeRegistryHandle) -> Self {
329        Self {
330            spec: PickerSourceSpec::no_args("colorscheme", "Pick a colour theme (live preview)."),
331            registry,
332        }
333    }
334}
335
336impl PickerSourceGenerator for ThemePickerSource {
337    fn spec(&self) -> &PickerSourceSpec {
338        &self.spec
339    }
340
341    fn init(&self, _ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
342        let pairs = self
343            .registry
344            .theme_names()
345            .into_iter()
346            .map(|name| {
347                let cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
348                (cand, RoutingPayload::Colorscheme { name })
349            })
350            .collect();
351        Ok(PickerInitResult::Inline(pairs))
352    }
353
354    fn accept(
355        &self,
356        _ctx: &PickerContext<'_>,
357        routing: &RoutingPayload,
358    ) -> SourceResult<PickerAcceptOutcome> {
359        match routing {
360            RoutingPayload::Colorscheme { name } => {
361                Ok(PickerAcceptOutcome::ApplyColorscheme { name: name.clone() })
362            }
363            other => Err(format!("colorscheme: unexpected routing payload {other:?}")),
364        }
365    }
366
367    fn preview(
368        &self,
369        _ctx: &PickerContext<'_>,
370        routing: &RoutingPayload,
371    ) -> Option<lattice_picker::PickerPreviewOutcome> {
372        match routing {
373            RoutingPayload::Colorscheme { name } => {
374                Some(lattice_picker::PickerPreviewOutcome::Colorscheme { name: name.clone() })
375            }
376            _ => None,
377        }
378    }
379}
380
381/// `gen:themes` — one candidate per registered colour theme. Drives
382/// `:colorscheme <Tab>`.
383///
384/// The same `theme_names()` the `colorscheme` picker enumerates, so the two
385/// surfaces cannot disagree about which themes exist. `:colorscheme` keeps its
386/// picker as well: `ArgSpec` carries `completion` and `picker` as separate
387/// fields precisely so an argument can offer inline `<Tab>` AND the richer
388/// surface — here the picker adds live preview, which completion cannot.
389pub struct ThemesGenerator {
390    pub registry: ThemeRegistryHandle,
391}
392
393impl CandidateGenerator for ThemesGenerator {
394    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
395        self.registry
396            .theme_names()
397            .into_iter()
398            .map(|name| RawCandidate {
399                insert_text: None,
400                text: name.clone(),
401                display: name,
402                kind: CandidateKind::Plain,
403                data: CandidateData::Plain,
404                source: None,
405                accept_action: None,
406                annotations: Vec::new(),
407                display_spans: Vec::new(),
408            })
409            .collect()
410    }
411}
412
413/// `gen:plugin-api-seams` — one candidate per WIT interface in the plugin-API
414/// catalog. Drives `:describe-plugin-api <Tab>`.
415///
416/// The catalog is generated from the WIT at build time and is static, so this
417/// needs no handle — and a seam renamed in the WIT changes the candidates with
418/// no second place to update.
419pub struct PluginApiSeamsGenerator;
420
421impl CandidateGenerator for PluginApiSeamsGenerator {
422    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
423        lattice_plugin_api::catalog()
424            .interfaces
425            .iter()
426            .map(|i| RawCandidate {
427                insert_text: None,
428                text: i.name.clone(),
429                display: i.name.clone(),
430                kind: CandidateKind::Plain,
431                data: CandidateData::Plain,
432                source: None,
433                accept_action: None,
434                annotations: Vec::new(),
435                display_spans: Vec::new(),
436            })
437            .collect()
438    }
439}
440
441/// `gen:plugin-api-formats` — the two export formats `:export-plugin-api`
442/// accepts. A two-element set is still worth completing: the alternative is
443/// reading the command's doc to learn that `md` is spelled `markdown`.
444pub struct PluginApiFormatsGenerator;
445
446impl CandidateGenerator for PluginApiFormatsGenerator {
447    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
448        ["markdown", "json"]
449            .into_iter()
450            .map(|s| RawCandidate {
451                insert_text: None,
452                text: s.to_string(),
453                display: s.to_string(),
454                kind: CandidateKind::Plain,
455                data: CandidateData::Plain,
456                source: None,
457                accept_action: None,
458                annotations: Vec::new(),
459                display_spans: Vec::new(),
460            })
461            .collect()
462    }
463}
464
465/// `gen:plugins` — one candidate per loaded plugin. Drives `:describe-plugin <Tab>` and the plugin manager's
466/// `:plugin-unload` / `:plugin-reload` / `:plugin-update`.
467///
468/// Both call sites carried this as a deferred comment — "`gen:plugins`
469/// completion is a follow-up … empty until Phase-8" in `ex_commands.rs`, and
470/// the same note on `lattice-plugin-loader`'s `string_arg`. Phase 8 landed and
471/// the comments did not get grepped, which is why the rule is now a test
472/// (`ex_string_args_have_completion.rs`) rather than a convention.
473///
474/// Holds a strong `Arc<PluginMetaRegistry>`, the same instance the host's
475/// `register_plugin` writes through and `:list-plugins` reads. It is registered
476/// once at boot and never swapped, so there is no stale-Arc hazard; the
477/// interior `RwLock` is what a mid-session load mutates, and reading it per
478/// `generate` is what makes a plugin loaded after boot appear under `<Tab>`.
479pub struct PluginsGenerator {
480    pub meta: std::sync::Arc<crate::dispatch::PluginMetaRegistry>,
481}
482
483impl CandidateGenerator for PluginsGenerator {
484    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
485        let Ok(map) = self.meta.0.read() else {
486            return Vec::new();
487        };
488        let mut names: Vec<String> = map.values().map(|m| m.name.clone()).collect();
489        names.sort();
490        names
491            .into_iter()
492            .map(|name| RawCandidate {
493                insert_text: None,
494                text: name.clone(),
495                display: name,
496                kind: CandidateKind::Plain,
497                data: CandidateData::Plain,
498                source: None,
499                accept_action: None,
500                annotations: Vec::new(),
501                display_spans: Vec::new(),
502            })
503            .collect()
504    }
505}
506
507/// MB.5: `gen:history-kinds` — completion source for `:history <Tab>`.
508/// Returns the two valid kind arguments: `commands` and `searches`.
509pub struct HistoryKindsGenerator;
510
511impl CandidateGenerator for HistoryKindsGenerator {
512    fn generate(&self, _ctx: &GenerateContext<'_>) -> Vec<RawCandidate> {
513        vec!["commands", "searches", "pane-buffers"]
514            .into_iter()
515            .map(|s| RawCandidate {
516                insert_text: None,
517                text: s.to_string(),
518                display: s.to_string(),
519                kind: CandidateKind::Plain,
520                data: CandidateData::Plain,
521                source: None,
522                accept_action: None,
523                annotations: Vec::new(),
524                display_spans: Vec::new(),
525            })
526            .collect()
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use lattice_core::Document;
534    use lattice_grammar::CommandRegistry;
535    use std::sync::Arc;
536
537    /// T.9.d follow-up: `gen:elements` (`:describe-element <Tab>`) enumerates
538    /// the live theme-element registry. Confirms the generator surfaces builtin
539    /// element names in sorted order, mirroring `ThemeRegistry::element_names`.
540    #[test]
541    fn elements_generator_produces_sorted_theme_element_names() {
542        let registry: ThemeRegistryHandle =
543            Arc::new(lattice_theme::InMemoryThemeRegistry::with_defaults());
544        let g = ElementsGenerator {
545            registry: registry.clone(),
546        };
547        let doc = Document::from_text("");
548        let buf = doc.buffer();
549        let cmd_reg = CommandRegistry::new();
550        let ctx = GenerateContext {
551            prefix: "",
552            buffer: buf,
553            registry: &cmd_reg,
554            case_sensitive: false,
555        };
556
557        let out = g.generate(&ctx);
558        let names: Vec<String> = out.iter().map(|c| c.text.clone()).collect();
559
560        assert!(
561            names.contains(&"syntax.keyword".to_string()),
562            "a builtin theme element must complete for `:describe-element`"
563        );
564        // The generator returns exactly what the registry enumerates, in the
565        // same (sorted) order — no drift, so popup ordering is stable.
566        assert_eq!(names, registry.element_names());
567        let mut sorted = names.clone();
568        sorted.sort();
569        assert_eq!(names, sorted, "candidates must be sorted");
570    }
571
572    /// `gen:plugins` enumerates the LOADED set, read per `generate` rather than
573    /// snapshotted, so a plugin loaded after boot completes without a restart.
574    #[test]
575    fn plugins_generator_enumerates_loaded_plugins_sorted() {
576        let meta = Arc::new(crate::dispatch::PluginMetaRegistry::default());
577        let g = PluginsGenerator { meta: meta.clone() };
578        let doc = Document::from_text("");
579        let buf = doc.buffer();
580        let cmd_reg = CommandRegistry::new();
581        let ctx = GenerateContext {
582            prefix: "",
583            buffer: buf,
584            registry: &cmd_reg,
585            case_sensitive: false,
586        };
587
588        assert!(
589            g.generate(&ctx).is_empty(),
590            "nothing loaded yet ⇒ no candidates"
591        );
592
593        // Registered the way the loader does it, AFTER the generator was built.
594        // A boot-time snapshot would list nothing here forever.
595        {
596            let mut map = meta.0.write().expect("meta registry lock");
597            for (id, name) in [(7u32, "zebra"), (8, "auto-pair")] {
598                map.insert(
599                    id,
600                    crate::dispatch::PluginMeta {
601                        name: name.to_string(),
602                        doc: String::new(),
603                    },
604                );
605            }
606        }
607
608        let names: Vec<String> = g.generate(&ctx).iter().map(|c| c.text.clone()).collect();
609        assert_eq!(
610            names,
611            vec!["auto-pair".to_string(), "zebra".to_string()],
612            "every loaded plugin completes, sorted — popup order must be stable \
613             even though the backing map is a HashMap"
614        );
615    }
616
617    /// A plugin's option namespace must be offered by `:customize <Tab>`.
618    ///
619    /// The regression this pins: the generator was built from
620    /// `GROUP_DECLS` + modes, both of which are populated at COMPILE time. A
621    /// plugin registers its options at RUNTIME, so `:customize
622    /// treesitter-context` worked while `<Tab>` never once mentioned it —
623    /// making a working group indistinguishable from an absent one, which is
624    /// the form the report arrived in.
625    #[test]
626    fn customize_completion_offers_runtime_plugin_namespaces() {
627        use lattice_config::ConfigRegistry;
628        use lattice_core::Document;
629        use lattice_grammar::CommandRegistry;
630
631        // Registered exactly the way the plugin config seam does it
632        // (`register_plugin_option` -> `try_register(ConfigOption::new(..))`),
633        // so the test cannot pass against a shape production never produces.
634        let config = std::sync::Arc::new(ConfigRegistry::default());
635        for (name, doc) in [
636            ("treesitter-context.enabled", "Enable it."),
637            ("treesitter-context.max-lines", "Cap the strip."),
638        ] {
639            config
640                .try_register(lattice_config::option::Option::<bool>::new(
641                    name.to_owned(),
642                    true,
643                    doc.to_owned(),
644                ))
645                .expect("fresh registry");
646        }
647
648        let modes = std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
649            lattice_mode::ModeRegistry::new(),
650        ));
651        let g = CustomizeNamesGenerator {
652            registry: std::sync::Arc::downgrade(&modes),
653            config: config.clone(),
654        };
655
656        let doc = Document::from_text("");
657        let buf = doc.buffer();
658        let cmd_reg = CommandRegistry::new();
659        let ctx = GenerateContext {
660            prefix: "",
661            buffer: buf,
662            registry: &cmd_reg,
663            case_sensitive: false,
664        };
665
666        let names: Vec<String> = g.generate(&ctx).into_iter().map(|c| c.text).collect();
667
668        assert!(
669            names.contains(&"treesitter-context".to_string()),
670            "the plugin namespace must be offered; got {names:?}"
671        );
672        // The namespace is offered ONCE, not once per option in it.
673        assert_eq!(
674            names.iter().filter(|n| *n == "treesitter-context").count(),
675            1
676        );
677        // And the compile-time groups are still there — this is an addition.
678        assert!(names.contains(&"editor".to_string()));
679    }
680}