1use lattice_config::ConfigRegistry;
34use lattice_grammar::source::SourceLocation;
35use lattice_grammar::{CommandInvocation, CommandRegistry};
36use lattice_keymap::{BindingMode, KeymapCapability, KeymapHandle, KeymapLayer};
37use lattice_mode::{
38 ActivationPolicy, CapabilitySet, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
39 ModeRegistry, OptionOverride, OptionOverrideSet, OverridePriority,
40};
41
42use crate::{
43 Component, PluginBudget, PluginHost, PluginHostError, PluginId, PluginManifest, TrustTier,
44 arm_store, classify_trap,
45};
46
47pub(crate) mod bindings {
48 wasmtime::component::bindgen!({
49 world: "modes-plugin",
50 path: "../lattice-wit/wit",
51 exports: { default: async },
55 });
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub(crate) enum PluginModeKind {
62 Major,
63 Minor,
64}
65
66pub(crate) struct PluginKeymapBinding {
70 pub mode: BindingMode,
71 pub chord: String,
72 pub command: String,
73}
74
75pub(crate) struct PluginModeDecl {
79 pub id: String,
80 pub kind: PluginModeKind,
81 pub policy: ActivationPolicy,
82 pub caps: CapabilitySet,
83 pub keymap: Vec<PluginKeymapBinding>,
84 pub target_language: Option<String>,
86 pub options: Vec<PluginModeOverride>,
91}
92
93pub(crate) struct PluginModeOverride {
96 pub name: String,
97 pub value: String,
98 pub priority: OverridePriority,
99}
100
101#[derive(Default)]
105pub(crate) struct ModeContributions {
106 recorded: Vec<PluginModeDecl>,
107}
108
109impl ModeContributions {
110 pub fn record(&mut self, decl: PluginModeDecl) {
112 self.recorded.push(decl);
113 }
114
115 pub fn take(&mut self) -> Vec<PluginModeDecl> {
117 std::mem::take(&mut self.recorded)
118 }
119}
120
121struct PluginMode {
128 id: ModeId,
129 kind: ModeKind,
130 policy: ActivationPolicy,
131 caps: CapabilitySet,
132 target_language: Option<String>,
137 options: OptionOverrideSet,
146}
147
148impl Mode for PluginMode {
149 type Guard = ();
150
151 fn id(&self) -> ModeId {
152 self.id
153 }
154
155 fn kind(&self) -> ModeKind {
156 self.kind
157 }
158
159 fn target_language(&self) -> Option<&str> {
160 self.target_language.as_deref()
161 }
162
163 fn activation_policy(&self) -> ActivationPolicy {
164 self.policy.clone()
165 }
166
167 fn required_capabilities(&self) -> CapabilitySet {
168 self.caps
169 }
170
171 fn options(&self) -> OptionOverrideSet {
177 self.options.clone()
178 }
179
180 fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
181 Box::pin(async { Ok(()) })
182 }
183}
184
185fn resolve_mode_options(
205 config: Option<&ConfigRegistry>,
206 mode_id: &str,
207 declared: &[PluginModeOverride],
208) -> OptionOverrideSet {
209 if declared.is_empty() {
210 return OptionOverrideSet::default();
211 }
212 let Some(config) = config else {
213 tracing::warn!(
214 mode = mode_id,
215 count = declared.len(),
216 "register-mode: option overrides skipped — no config registry wired"
217 );
218 return OptionOverrideSet::default();
219 };
220
221 let mut set = OptionOverrideSet::with_capacity(declared.len());
222 for ov in declared {
223 let spec = format!("{}={}", ov.name, ov.value);
228 match config.parse_for_buffer_local(&spec) {
229 Ok((type_id, value, canonical)) => {
230 tracing::debug!(
231 mode = mode_id,
232 option = %canonical,
233 value = %ov.value,
234 "register-mode: option override resolved"
235 );
236 set.push(OptionOverride {
237 option_type_id: type_id,
238 value,
239 priority: ov.priority,
240 });
241 }
242 Err(error) => tracing::warn!(
243 mode = mode_id,
244 option = %ov.name,
245 value = %ov.value,
246 %error,
247 "register-mode: option override skipped; the mode's other options still apply"
248 ),
249 }
250 }
251 set
252}
253
254pub(crate) fn register_plugin_mode(
266 registry: &mut ModeRegistry,
267 config: Option<&ConfigRegistry>,
268 decl: &PluginModeDecl,
269) -> Option<ModeId> {
270 let kind = match decl.kind {
271 PluginModeKind::Major => ModeKind::Major,
272 PluginModeKind::Minor => ModeKind::Minor,
273 };
274 let target_language = match (decl.kind, decl.target_language.as_deref()) {
275 (PluginModeKind::Major, lang) => lang.map(str::to_owned),
276 (PluginModeKind::Minor, Some(lang)) => {
277 tracing::warn!(
278 mode = %decl.id,
279 %lang,
280 "register-mode: target-language ignored on a minor mode; only a \
281 major can own a language (a minor rides one via activation-policy)"
282 );
283 None
284 }
285 (PluginModeKind::Minor, None) => None,
286 };
287 let mode = PluginMode {
288 id: ModeId::new(&decl.id),
289 kind,
290 policy: decl.policy.clone(),
291 caps: decl.caps,
292 target_language,
293 options: resolve_mode_options(config, &decl.id, &decl.options),
294 };
295 match registry.register_available(mode) {
299 Ok(id) => Some(id),
300 Err(error) => {
301 tracing::warn!(mode = %decl.id, %error, "register-mode rejected by the registry");
302 None
303 }
304 }
305}
306
307pub(crate) fn bind_mode_keymap(
321 keymap: &KeymapHandle,
322 commands: &CommandRegistry,
323 plugin_id: u32,
324 mode_id: &ModeId,
325 kind: ModeKind,
326 bindings: &[PluginKeymapBinding],
327) -> usize {
328 let capability = KeymapCapability::OwnedLayer { mode_id: *mode_id };
329 let layer = match kind {
330 ModeKind::Major => KeymapLayer::MajorMode(*mode_id),
331 ModeKind::Minor => KeymapLayer::MinorMode(*mode_id),
332 };
333 let mut bound = 0;
334 for binding in bindings {
335 let Some(command_id) = commands.id_by_name(&binding.command) else {
336 tracing::warn!(
337 mode = %mode_id.as_str(),
338 command = %binding.command,
339 "mode keymap binding skipped: command not registered"
340 );
341 continue;
342 };
343 match keymap.try_bind_chord_string(
344 capability,
345 layer,
346 binding.mode,
347 &binding.chord,
348 CommandInvocation::of(command_id),
349 SourceLocation::plugin(plugin_id),
350 ) {
351 Ok(()) => bound += 1,
352 Err(error) => {
353 tracing::warn!(
354 mode = %mode_id.as_str(),
355 chord = %binding.chord,
356 %error,
357 "mode keymap binding skipped"
358 );
359 }
360 }
361 }
362 bound
363}
364
365impl PluginHost {
366 pub async fn spawn_mode_plugin(
379 &self,
380 component: &Component,
381 manifest: &PluginManifest,
382 tier: TrustTier,
383 budget: PluginBudget,
384 registry: &mut ModeRegistry,
385 commands: &CommandRegistry,
386 keymap: &KeymapHandle,
387 config: Option<&ConfigRegistry>,
388 ) -> Result<(PluginId, Vec<ModeId>), PluginHostError> {
389 let (wasi, outcome, _data_dir) = self.build_plugin_wasi(manifest, tier);
390 for denied in &outcome.denied {
391 tracing::warn!(
392 plugin = %manifest.id,
393 capability = ?denied,
394 "mode plugin loaded with a withheld capability (reduced function)"
395 );
396 }
397 let mut store = self.new_store(wasi, outcome.grant, budget, Some(&manifest.id))?;
398 let bindings =
399 bindings::ModesPlugin::instantiate_async(&mut store, component, &self.linker)
400 .await
401 .map_err(|e| PluginHostError::Instantiate(e.into()))?;
402 let plugin_id = self.alloc_id();
403 store.data_mut().log_ctx = self.log_ctx_for(plugin_id);
406
407 arm_store(&mut store, budget)?;
408 bindings
409 .call_register_modes(&mut store)
410 .await
411 .map_err(|source| PluginHostError::Trap {
412 func: "register-modes",
413 kind: classify_trap(&source),
414 source: source.into(),
415 })?;
416
417 let recorded = store.data_mut().mode_contributions.take();
422 let mut ids = Vec::with_capacity(recorded.len());
423 for decl in recorded {
424 if let Some(id) = register_plugin_mode(registry, config, &decl) {
425 let kind = match decl.kind {
426 PluginModeKind::Major => ModeKind::Major,
427 PluginModeKind::Minor => ModeKind::Minor,
428 };
429 bind_mode_keymap(keymap, commands, plugin_id.0, &id, kind, &decl.keymap);
430 ids.push(id);
431 }
432 }
433 Ok((plugin_id, ids))
440 }
441}
442
443#[cfg(test)]
444mod tests {
445 #![allow(clippy::unwrap_used, clippy::panic)]
446
447 use super::*;
448
449 fn minor(id: &str) -> PluginModeDecl {
450 PluginModeDecl {
451 id: id.to_string(),
452 kind: PluginModeKind::Minor,
453 policy: ActivationPolicy::Manual,
454 caps: CapabilitySet::empty(),
455 keymap: Vec::new(),
456 target_language: None,
457 options: Vec::new(),
458 }
459 }
460
461 fn major_for(id: &str, lang: &str) -> PluginModeDecl {
463 PluginModeDecl {
464 id: id.to_string(),
465 kind: PluginModeKind::Major,
466 policy: ActivationPolicy::Manual,
467 caps: CapabilitySet::empty(),
468 keymap: Vec::new(),
469 target_language: Some(lang.to_string()),
470 options: Vec::new(),
471 }
472 }
473
474 #[test]
475 fn registers_a_minor_mode_into_the_registry() {
476 let mut registry = ModeRegistry::default();
477 let id = register_plugin_mode(&mut registry, None, &minor("git-blame-mode"))
478 .expect("a well-formed minor mode registers");
479 assert_eq!(id.as_str(), "git-blame-mode");
480 assert!(registry.is_registered(ModeId::new("git-blame-mode")));
481 }
482
483 #[test]
484 fn a_bare_id_without_the_mode_suffix_is_rejected() {
485 let mut registry = ModeRegistry::default();
486 assert!(
487 register_plugin_mode(&mut registry, None, &minor("git-blame")).is_none(),
488 "the registry enforces the `-mode` suffix"
489 );
490 assert!(!registry.is_registered(ModeId::new("git-blame")));
491 }
492
493 #[test]
498 fn a_major_registers_and_claims_its_language() {
499 let mut registry = ModeRegistry::default();
500 let id = register_plugin_mode(&mut registry, None, &major_for("org-mode", "org"))
501 .expect("a well-formed major registers");
502 assert_eq!(id.as_str(), "org-mode");
503 assert_eq!(
504 registry.get(id).expect("registered").kind(),
505 ModeKind::Major,
506 "it registers AS a major, not silently downgraded to a minor"
507 );
508 assert_eq!(
509 registry.find_major_for_lang("org"),
510 Some(id),
511 "and the language index resolves documents onto it"
512 );
513 }
514
515 #[test]
518 fn a_major_without_a_language_registers_but_claims_nothing() {
519 let mut registry = ModeRegistry::default();
520 let mut decl = major_for("scratch-mode", "unused");
521 decl.target_language = None;
522 let id = register_plugin_mode(&mut registry, None, &decl).expect("registers");
523 assert_eq!(
524 registry.get(id).expect("registered").kind(),
525 ModeKind::Major
526 );
527 assert_eq!(registry.find_major_for_lang("unused"), None);
528 }
529
530 #[test]
533 fn a_minor_claiming_a_language_registers_without_the_claim() {
534 let mut registry = ModeRegistry::default();
535 let mut decl = minor("org-todo-mode");
536 decl.target_language = Some("org".to_string());
537 let id =
538 register_plugin_mode(&mut registry, None, &decl).expect("the mode still registers");
539 assert_eq!(
540 registry.get(id).expect("registered").kind(),
541 ModeKind::Minor
542 );
543 assert_eq!(
544 registry.find_major_for_lang("org"),
545 None,
546 "the claim was dropped, so org documents do not resolve onto a minor"
547 );
548 }
549
550 #[test]
553 fn a_majors_keymap_lands_in_its_own_major_layer() {
554 let keymap = KeymapHandle::new();
555 let mut commands = CommandRegistry::new();
556 let _ = lattice_grammar::ex_commands::populate(&mut commands);
557 let mode_id = ModeId::new("org-mode");
558 let bindings = vec![PluginKeymapBinding {
559 mode: BindingMode::Normal,
560 chord: "<C-s>".to_string(),
561 command: "ex:write".to_string(),
562 }];
563
564 let bound = bind_mode_keymap(&keymap, &commands, 3, &mode_id, ModeKind::Major, &bindings);
565 assert_eq!(bound, 1, "the binding landed");
566
567 let chord = lattice_protocol::parse_chord_sequence("<C-s>").unwrap();
568 assert!(
569 matches!(
570 keymap.lookup_with_context(BindingMode::Normal, &chord, &[mode_id]),
571 lattice_keymap::LookupResult::Bound { .. }
572 ),
573 "resolves when the major is active"
574 );
575 assert!(
576 matches!(
577 keymap.lookup_with_context(BindingMode::Normal, &chord, &[]),
578 lattice_keymap::LookupResult::Unbound
579 ),
580 "a major's layer is gated too — it is not always-on (K.1.c)"
581 );
582 }
583
584 #[test]
585 fn a_duplicate_id_is_rejected_keeping_the_original() {
586 let mut registry = ModeRegistry::default();
587 assert!(register_plugin_mode(&mut registry, None, &minor("dup-mode")).is_some());
588 assert!(
589 register_plugin_mode(&mut registry, None, &minor("dup-mode")).is_none(),
590 "a second registration under the same id is refused"
591 );
592 }
593
594 #[test]
595 fn capabilities_and_policy_are_carried_onto_the_registered_mode() {
596 let mut registry = ModeRegistry::default();
597 let decl = PluginModeDecl {
598 id: "lsp-lens-mode".to_string(),
599 kind: PluginModeKind::Minor,
600 policy: ActivationPolicy::Universal,
601 caps: CapabilitySet::LSP | CapabilitySet::DIAGNOSTICS,
602 keymap: Vec::new(),
603 target_language: None,
604 options: Vec::new(),
605 };
606 let id = register_plugin_mode(&mut registry, None, &decl).unwrap();
607 let mode = registry.get(id).expect("registered");
608 assert!(matches!(
609 mode.activation_policy(),
610 ActivationPolicy::Universal
611 ));
612 assert_eq!(
613 mode.required_capabilities(),
614 CapabilitySet::LSP | CapabilitySet::DIAGNOSTICS
615 );
616 }
617
618 fn config() -> ConfigRegistry {
621 let r = ConfigRegistry::new();
622 r.init_from_linkme();
623 r
624 }
625
626 fn override_of(name: &str, value: &str) -> PluginModeOverride {
627 PluginModeOverride {
628 name: name.to_string(),
629 value: value.to_string(),
630 priority: OverridePriority::Normal,
631 }
632 }
633
634 #[test]
639 fn a_declared_option_override_reaches_the_registered_mode() {
640 use lattice_config::FoldMethodOption;
641 use lattice_core::FoldMethod;
642
643 let cfg = config();
644 let mut registry = ModeRegistry::default();
645 let mut decl = major_for("org-mode", "org");
646 decl.options = vec![override_of("foldmethod", "syntax")];
647
648 let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
649 let mode = registry.get(id).expect("registered");
650
651 let set = mode.options();
652 assert_eq!(set.len(), 1, "the override crossed");
653 let ov = set.iter().next().expect("one override");
654 assert_eq!(
655 ov.option_type_id,
656 std::any::TypeId::of::<FoldMethodOption>(),
657 "resolved to the NATIVE option's identity, not a name the mode kept"
658 );
659 assert_eq!(
660 ov.downcast_value::<FoldMethod>(),
661 Some(&FoldMethod::Syntax),
662 "and the string was coerced by the option's own parser"
663 );
664 assert_eq!(ov.priority, OverridePriority::Normal);
665 }
666
667 #[test]
671 fn an_override_does_not_write_the_global_option() {
672 use lattice_config::FoldMethodOption;
673 use lattice_core::FoldMethod;
674
675 let cfg = config();
676 let before = *cfg.get_typed::<FoldMethodOption>().expect("registered");
677 assert_eq!(before, FoldMethod::Manual, "sanity: the shipped default");
678
679 let mut registry = ModeRegistry::default();
680 let mut decl = major_for("org-mode", "org");
681 decl.options = vec![override_of("foldmethod", "syntax")];
682 register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
683
684 assert_eq!(
685 *cfg.get_typed::<FoldMethodOption>().expect("registered"),
686 FoldMethod::Manual,
687 "the global value is untouched — the override is a layer"
688 );
689 }
690
691 #[test]
696 fn an_unresolvable_override_is_skipped_and_the_rest_apply() {
697 use lattice_config::Number;
698
699 let cfg = config();
700 let mut registry = ModeRegistry::default();
701 let mut decl = minor("test-opts-mode");
702 decl.options = vec![
703 override_of("no-such-option-anywhere", "true"),
704 override_of("number", "false"),
705 ];
706
707 let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
708 let set = registry.get(id).expect("registered").options();
709
710 assert_eq!(set.len(), 1, "the bad entry is dropped, not the set");
711 assert_eq!(
712 set.iter().next().expect("one").option_type_id,
713 std::any::TypeId::of::<Number>(),
714 "and it is the GOOD one that survived"
715 );
716 }
717
718 #[test]
722 fn a_value_the_option_rejects_is_skipped() {
723 let cfg = config();
724 let mut registry = ModeRegistry::default();
725 let mut decl = minor("test-badvalue-mode");
726 decl.options = vec![override_of("foldmethod", "definitely-not-a-fold-method")];
727
728 let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
729 assert!(
730 registry.get(id).expect("registered").options().is_empty(),
731 "an invalid value contributes nothing"
732 );
733 }
734
735 #[test]
741 fn a_plugin_declared_option_cannot_yet_be_overridden() {
742 let cfg = config();
743 assert!(
744 crate::config_host::register_plugin_option(
745 &cfg,
746 "testplug.inline-images",
747 crate::config_host::PluginOptionKind::Boolean,
748 "false",
749 "a plugin-registered option",
750 ),
751 "sanity: the option registers"
752 );
753 assert!(
754 cfg.lookup("testplug.inline-images").is_some(),
755 "sanity: and is findable by name"
756 );
757
758 let mut registry = ModeRegistry::default();
759 let mut decl = minor("test-ownopt-mode");
760 decl.options = vec![override_of("testplug.inline-images", "true")];
761
762 let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
763 assert!(
764 registry.get(id).expect("registered").options().is_empty(),
765 "a plugin's own option has no TypeId, so it cannot be an override yet"
766 );
767 }
768
769 #[test]
773 fn priority_crosses_the_seam() {
774 let cfg = config();
775 let mut registry = ModeRegistry::default();
776 let mut decl = minor("test-priority-mode");
777 decl.options = vec![PluginModeOverride {
778 name: "number".to_string(),
779 value: "false".to_string(),
780 priority: OverridePriority::High,
781 }];
782
783 let id = register_plugin_mode(&mut registry, Some(&cfg), &decl).expect("registers");
784 let set = registry.get(id).expect("registered").options();
785 assert_eq!(
786 set.iter().next().expect("one").priority,
787 OverridePriority::High
788 );
789 }
790
791 #[test]
795 fn without_a_config_registry_the_mode_still_registers() {
796 let mut registry = ModeRegistry::default();
797 let mut decl = minor("test-noconfig-mode");
798 decl.options = vec![override_of("number", "false")];
799
800 let id = register_plugin_mode(&mut registry, None, &decl).expect("registers anyway");
801 assert!(registry.get(id).expect("registered").options().is_empty());
802 }
803
804 #[test]
805 fn keymap_binding_lands_in_the_owned_layer_and_resolves() {
806 use lattice_keymap::LookupResult;
807
808 let mut commands = CommandRegistry::new();
810 let _ = lattice_grammar::ex_commands::populate(&mut commands);
811 let target = "ex:write";
812 assert!(
813 commands.id_by_name(target).is_some(),
814 "sanity: target exists"
815 );
816
817 let keymap = KeymapHandle::new();
818 let mode_id = ModeId::new("git-blame-mode");
819 let bindings = vec![PluginKeymapBinding {
820 mode: BindingMode::Normal,
821 chord: "<C-s>".to_string(),
822 command: target.to_string(),
823 }];
824 let bound = bind_mode_keymap(&keymap, &commands, 7, &mode_id, ModeKind::Minor, &bindings);
825 assert_eq!(bound, 1, "the well-formed binding landed");
826
827 let chord = lattice_protocol::parse_chord_sequence("<C-s>").expect("chord parses");
829
830 assert!(
832 matches!(
833 keymap.lookup_with_context(BindingMode::Normal, &chord, &[mode_id]),
834 LookupResult::Bound { .. }
835 ),
836 "the chord resolves in the mode's owned layer when active"
837 );
838 assert!(
839 matches!(
840 keymap.lookup_with_context(BindingMode::Normal, &chord, &[]),
841 LookupResult::Unbound
842 ),
843 "with the mode inactive the gated binding does not fire"
844 );
845 }
846
847 #[test]
848 fn keymap_binding_to_an_unknown_command_is_skipped() {
849 let commands = CommandRegistry::new();
850 let keymap = KeymapHandle::new();
851 let bindings = vec![PluginKeymapBinding {
852 mode: BindingMode::Normal,
853 chord: "gx".to_string(),
854 command: "ex:does-not-exist".to_string(),
855 }];
856 let bound = bind_mode_keymap(
857 &keymap,
858 &commands,
859 1,
860 &ModeId::new("x-mode"),
861 ModeKind::Minor,
862 &bindings,
863 );
864 assert_eq!(
865 bound, 0,
866 "an unknown command binds nothing (logged + skipped)"
867 );
868 }
869}