Skip to main content

lattice_mode/
registry.rs

1//! `ModeRegistry`: register modes, look them up, drive
2//! activation / deactivation against a per-buffer `ActiveModes`
3//! and an App-owned [`GuardStoreHandle`].
4//!
5//! M-async.3: activation is **spawn-based with sequential
6//! cascade**. The sync prefix walks the requested mode + its
7//! `implies()` tree, validates each, mutates `active_modes` for
8//! each, and builds an ordered cascade plan
9//! (`Vec<CascadeStep>`). One task is spawned that walks the
10//! plan in DFS order, awaiting each step's `on_activate.await`
11//! before moving to the next. This guarantees a parent's
12//! lifecycle resolves before its implied children's begin --
13//! no sub-mode reads parent's not-yet-written state.
14//!
15//! On lifecycle error the spawned task publishes
16//! `ModeActivationFailed` for the failing step, then publishes
17//! `ModeActivationFailed { reason: "cascade aborted by X" }`
18//! for every remaining (unrun) step. An App-side subscriber
19//! (`drain_mode_lifecycle_events`) rolls back `active_modes` /
20//! `mode_guards` on each.
21//!
22//! Deactivation stays synchronous (Drop is sync): the
23//! dispatcher locks the store, removes the Guard, drops it. The
24//! `MajorExiting` / `MinorDeactivated` event publishes before
25//! the drop fires.
26//!
27//! Validation order before spawning:
28//! 1. Mode is registered (`ModeActivationError::NotRegistered`).
29//! 2. Mode kind matches the call (`WrongKind`).
30//! 3. Buffer satisfies required capabilities
31//!    (`MissingCapability`).
32//! 4. No conflict with already-active modes (`Conflict`).
33//! 5. All `implies` dependencies are registered
34//!    (`UnregisteredDependency`).
35//!
36//! Lifecycle errors (from `on_activate.await`) become
37//! `ModeActivationFailed` events on the bus; the spawned task
38//! never returns them to the caller (the caller already
39//! returned `Ok(())`).
40
41use std::collections::HashMap;
42use std::sync::Arc;
43
44use lattice_core::BufferKind;
45use lattice_protocol::Event;
46use lattice_protocol::ids::BufferId;
47
48use crate::active::ActiveModes;
49use crate::capability::CapabilitySet;
50use crate::context::ModeContext;
51use crate::error::ModeActivationError;
52use crate::event::ModeEvent;
53use crate::guards::GuardStoreHandle;
54use crate::mode::{DynMode, Mode, ModeId, ModeKind};
55
56/// Why a registration failed.
57#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
58pub enum RegistrationError {
59    /// A mode with this id is already registered. The registry never
60    /// replaces a mode in place; a reload must
61    /// [`unregister`](ModeRegistry::unregister) first.
62    #[error("mode `{0}` is already registered")]
63    Duplicate(ModeId),
64    /// The mode id does not end in `-mode`. Every mode id must carry
65    /// the conventional `-mode` suffix (`snippet-mode`, `emacs-keys-mode`,
66    /// …); M.2 *groups* (which never end in `-mode`) are not modes and
67    /// never reach this path. Enforced at the single registration
68    /// choke point so the convention can't silently drift (mode_id.rs).
69    #[error("mode id `{0}` must end in `-mode` (naming convention)")]
70    MissingModeSuffix(ModeId),
71}
72
73/// Mode registry. Owns the catalogue of registered modes
74/// (`Arc<dyn DynMode>`) and drives activation / deactivation.
75///
76/// H.2 (2026-05-31): `kind_index` maps each [`BufferKind`] to the
77/// major mode that declared `target_buffer_kind() == Some(kind)`.
78/// Populated at register-time; first registration wins (subsequent
79/// claims log a warning rather than failing, so foundation
80/// registration order and feature-crate registration order can
81/// interleave deterministically).
82///
83/// Registration (`&mut self`) happens at boot and on plugin load, through
84/// the copy-on-write [`ModeRegistryHandle`]; every other method is `&self`
85/// and is read from the editor actor. The activation methods do not own
86/// per-buffer state: the caller passes the buffer's [`ActiveModes`] and the
87/// editor-wide [`GuardStoreHandle`], config, event bus and services.
88///
89/// # Examples
90///
91/// ```
92/// use lattice_core::BufferKind;
93/// use lattice_mode::{
94///     register_foundation_modes, LifecycleFuture, MessagesMode, Mode, ModeContext, ModeId,
95///     ModeKind, ModeRegistry, RegistrationError, TextMode,
96/// };
97///
98/// let mut registry = ModeRegistry::new();
99/// register_foundation_modes(&mut registry);
100/// assert!(registry.is_registered(TextMode::mode_id()));
101/// // Kind-bound majors are found by kind, without a host-side `match`.
102/// assert_eq!(
103///     registry.find_major_for_kind(BufferKind::Messages),
104///     Some(MessagesMode::mode_id()),
105/// );
106/// // The image major PRESENTS its files rather than loading them as text.
107/// assert_eq!(
108///     registry.presenting_major_for_path(std::path::Path::new("diagram.PNG")),
109///     Some(ModeId::new("image-mode")),
110/// );
111///
112/// struct Bare(&'static str);
113/// impl Mode for Bare {
114///     type Guard = ();
115///     fn id(&self) -> ModeId { ModeId::new(self.0) }
116///     fn kind(&self) -> ModeKind { ModeKind::Minor }
117///     fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
118///         Box::pin(async { Ok(()) })
119///     }
120/// }
121/// // The `-mode` suffix is enforced, and ids are unique.
122/// assert!(matches!(registry.register(Bare("zen")), Err(RegistrationError::MissingModeSuffix(_))));
123/// let id = registry.register(Bare("zen-mode")).unwrap();
124/// assert!(matches!(registry.register(Bare("zen-mode")), Err(RegistrationError::Duplicate(_))));
125/// assert!(registry.is_minor_enabled(&id)); // native registration enables it
126/// assert!(registry.unregister(id));
127/// ```
128#[derive(Clone)]
129pub struct ModeRegistry {
130    modes: HashMap<ModeId, Arc<dyn DynMode>>,
131    kind_index: HashMap<BufferKind, ModeId>,
132    /// The peer of `kind_index` for the language dispatch path.
133    /// Maps a canonical language name (`Lang::name()`) to the major that
134    /// declared `target_language() == Some(name)`. Same rules as
135    /// `kind_index`: populated at register-time, first registration wins,
136    /// freed on `unregister`. Keyed by `String` rather than a `Lang`
137    /// because a plugin language's identity IS its name — the host has no
138    /// enum arm for it (OM.1).
139    lang_index: HashMap<String, ModeId>,
140    /// Lowercase extension → the major that PRESENTS that file type without
141    /// loading it as text. Built at register-time, like `lang_index`.
142    presents_index: HashMap<String, ModeId>,
143    /// CI.1/CI.3: minor modes the user has ENABLED. `auto_activatable_minors`
144    /// gates on this — a registered minor auto-activates only when enabled.
145    /// Native modes are auto-enabled at [`register`](Self::register); plugin
146    /// modes register via [`register_available`](Self::register_available) and
147    /// stay off until `enable-mode` / init.rs enables them (config-and-init.md).
148    /// Declaration (available) ≠ enablement (on) — the emacs global-minor-mode
149    /// model, so the user, not the plugin author, owns which extensions are live.
150    enabled: std::collections::HashSet<ModeId>,
151}
152
153/// The runtime-mutable mode-registry handle the editor holds and shares as a
154/// service. `ArcSwap` gives wait-free reads on the keymap-resolution /
155/// mode-activation paths and copy-on-write RCU writes for runtime mode
156/// registration (a mode plugin loaded via `:plugin-load`, PL8.B). Reads take a
157/// snapshot (`load`/`load_full`); a mode load/unload clones-mutates-stores a
158/// fresh registry. The plugin loader reaches this via
159/// `service::<ModeRegistryHandle>()`.
160pub type ModeRegistryHandle = std::sync::Arc<arc_swap::ArcSwap<ModeRegistry>>;
161
162impl Default for ModeRegistry {
163    fn default() -> Self {
164        Self::new()
165    }
166}
167
168impl std::fmt::Debug for ModeRegistry {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        f.debug_struct("ModeRegistry")
171            .field("count", &self.modes.len())
172            .finish_non_exhaustive()
173    }
174}
175
176impl ModeRegistry {
177    /// An empty registry. The host then calls
178    /// [`register_foundation_modes`](crate::register_foundation_modes) and
179    /// each feature crate's `register_<x>_modes`.
180    pub fn new() -> Self {
181        Self {
182            modes: HashMap::new(),
183            kind_index: HashMap::new(),
184            lang_index: HashMap::new(),
185            presents_index: HashMap::new(),
186            enabled: std::collections::HashSet::new(),
187        }
188    }
189
190    /// Register a mode. Same id twice is a `Duplicate` error.
191    ///
192    /// H.2: if `mode.target_buffer_kind()` is `Some(kind)` and no
193    /// major has claimed `kind` yet, the mode is recorded as the
194    /// default major for that kind (queryable via
195    /// [`Self::find_major_for_kind`]). Subsequent claims for the
196    /// same `kind` log a `tracing::warn!` and leave the existing
197    /// binding in place — clobbering is treated as a developer bug,
198    /// not a hot-swap mechanism.
199    pub fn register<M: Mode>(&mut self, mode: M) -> Result<ModeId, RegistrationError> {
200        self.register_inner(mode, /* enable */ true)
201    }
202
203    /// Register a mode WITHOUT enabling it (CI.3) — the plugin path. The mode is
204    /// **available** (in the registry, its keymap layer exists, `:describe-mode`
205    /// and `:ls`-style introspection see it) but does NOT auto-activate until the
206    /// user enables it (`enable-mode` / an `init.rs` `on-plugin-loaded` handler).
207    /// Used by `register_plugin_mode`; native modes use [`register`](Self::register)
208    /// (auto-enabled). Declaration ≠ enablement (config-and-init.md §6).
209    pub fn register_available<M: Mode>(&mut self, mode: M) -> Result<ModeId, RegistrationError> {
210        self.register_inner(mode, /* enable */ false)
211    }
212
213    fn register_inner<M: Mode>(
214        &mut self,
215        mode: M,
216        enable: bool,
217    ) -> Result<ModeId, RegistrationError> {
218        let id = <M as Mode>::id(&mode);
219        // Convention (mode_id.rs): every mode id ends in `-mode`. This is
220        // the single choke point every built-in and plugin mode flows
221        // through, so enforcing here catches a missing suffix (the
222        // `emacs-keys` → `emacs-keys-mode` slip) uniformly. Groups (M.2)
223        // are not modes and never reach `register`, so they keep their
224        // suffixless ids.
225        if !id.as_str().ends_with("-mode") {
226            return Err(RegistrationError::MissingModeSuffix(id));
227        }
228        if self.modes.contains_key(&id) {
229            return Err(RegistrationError::Duplicate(id));
230        }
231        let target_kind = <M as Mode>::target_buffer_kind(&mode);
232        // OM.1: a language claim is only meaningful from a MAJOR — a buffer
233        // has exactly one, and resolving a minor through the language index
234        // would install it as the buffer's major. `kind_index` does not
235        // filter this way (H.2 chose to respect whatever a mode declares),
236        // but a minor claiming a *language* is reachable from plugin input
237        // in a way a mis-declared kind never was, so it is refused here and
238        // said out loud rather than indexed and wondered about later.
239        let target_lang = match <M as Mode>::kind(&mode) {
240            ModeKind::Major => <M as Mode>::target_language(&mode).map(str::to_owned),
241            ModeKind::Minor => {
242                if let Some(lang) = <M as Mode>::target_language(&mode) {
243                    tracing::warn!(
244                        mode = %id,
245                        %lang,
246                        "ModeRegistry: ignoring target_language on a MINOR mode; \
247                         only a major can own a language"
248                    );
249                }
250                None
251            }
252        };
253        // A major that PRESENTS a file type claims its extensions, so the
254        // open path can decide not to read the file as text before it tries.
255        // Minors never present: the decision belongs to the buffer's identity.
256        let presented: Vec<String> = if matches!(<M as Mode>::kind(&mode), ModeKind::Major) {
257            <M as Mode>::presents_extensions(&mode)
258                .iter()
259                .map(|e| e.to_ascii_lowercase())
260                .collect()
261        } else {
262            Vec::new()
263        };
264        let arc: Arc<dyn DynMode> = Arc::new(mode);
265        self.modes.insert(id, arc);
266        for ext in presented {
267            match self.presents_index.entry(ext) {
268                std::collections::hash_map::Entry::Vacant(e) => {
269                    e.insert(id);
270                }
271                std::collections::hash_map::Entry::Occupied(e) => {
272                    tracing::warn!(
273                        existing = %e.get(),
274                        rejected = %id,
275                        extension = %e.key(),
276                        "ModeRegistry: ignoring duplicate presents_extensions \
277                         claim; first registration wins"
278                    );
279                }
280            }
281        }
282        if let Some(lang) = target_lang {
283            match self.lang_index.entry(lang) {
284                std::collections::hash_map::Entry::Vacant(e) => {
285                    e.insert(id);
286                }
287                std::collections::hash_map::Entry::Occupied(e) => {
288                    tracing::warn!(
289                        existing = %e.get(),
290                        rejected = %id,
291                        lang = %e.key(),
292                        "ModeRegistry: ignoring duplicate target_language \
293                         claim; first registration wins"
294                    );
295                }
296            }
297        }
298        if let Some(kind) = target_kind {
299            match self.kind_index.entry(kind) {
300                std::collections::hash_map::Entry::Vacant(e) => {
301                    e.insert(id);
302                }
303                std::collections::hash_map::Entry::Occupied(e) => {
304                    tracing::warn!(
305                        existing = %e.get(),
306                        rejected = %id,
307                        ?kind,
308                        "ModeRegistry: ignoring duplicate target_buffer_kind \
309                         claim; first registration wins"
310                    );
311                }
312            }
313        }
314        if enable {
315            self.enabled.insert(id);
316        }
317        Ok(id)
318    }
319
320    /// True iff this id is registered (any kind).
321    pub fn is_registered(&self, id: ModeId) -> bool {
322        self.modes.contains_key(&id)
323    }
324
325    /// Enable or disable a registered minor mode globally (CI.4). Enabling makes
326    /// it eligible for auto-activation per its `ActivationPolicy`
327    /// ([`auto_activatable_minors`](Self::auto_activatable_minors)); disabling
328    /// removes it from the enabled set. A no-op for an unregistered id (the
329    /// caller logs). The host re-activates open buffers after enabling
330    /// (config-and-init.md §6); the registry only holds the flag.
331    pub fn set_minor_enabled(&mut self, id: ModeId, enabled: bool) {
332        if enabled {
333            self.enabled.insert(id);
334        } else {
335            self.enabled.remove(&id);
336        }
337    }
338
339    /// True iff this minor mode is enabled (CI.3) — the auto-activation gate.
340    pub fn is_minor_enabled(&self, id: &ModeId) -> bool {
341        self.enabled.contains(id)
342    }
343
344    /// Remove a registered mode, the teardown seam for a plugin reload / unload
345    /// (PH7.12b). The registry is otherwise register-only, so without this a
346    /// plugin reload would hit [`RegistrationError::Duplicate`] on the second
347    /// `register-mode`, and the `modes` map would grow across reloads. The
348    /// caller drives this with the [`ModeId`]s `spawn_mode_plugin` returned, so
349    /// removal is by id (the registry keeps no plugin-id provenance — the host
350    /// owns the id↔plugin mapping in its teardown bundle). Also drops any
351    /// `kind_index` entry pointing at this mode so a re-register can re-claim
352    /// the kind. Returns `true` if the mode was present (idempotent no-op on a
353    /// second unload).
354    pub fn unregister(&mut self, id: ModeId) -> bool {
355        let removed = self.modes.remove(&id).is_some();
356        if removed {
357            self.kind_index.retain(|_, claimed| *claimed != id);
358            // OM.1: a plugin's major is unregistered on unload, and a stale
359            // language claim would resolve documents to a mode that no
360            // longer exists.
361            self.lang_index.retain(|_, claimed| *claimed != id);
362            self.presents_index.retain(|_, claimed| *claimed != id);
363        }
364        removed
365    }
366
367    /// Look up a registered mode by id.
368    pub fn get(&self, id: ModeId) -> Option<Arc<dyn DynMode>> {
369        self.modes.get(&id).cloned()
370    }
371
372    /// Look up the major mode declared as the default for a
373    /// given [`BufferKind`] (via `Mode::target_buffer_kind`).
374    /// Returns `None` for kinds with no declared major
375    /// (e.g. [`BufferKind::Document`], which dispatches through
376    /// language detection rather than a kind-bound major) (H.2).
377    ///
378    /// Index built at register-time, so lookup is `HashMap`-cheap.
379    pub fn find_major_for_kind(&self, kind: BufferKind) -> Option<ModeId> {
380        self.kind_index.get(&kind).copied()
381    }
382
383    /// Look up the major mode declared as the default for a
384    /// language, by canonical name (`Lang::name()` — `"rust"`,
385    /// `"org"`). The peer of [`find_major_for_kind`](Self::find_major_for_kind)
386    /// for [`BufferKind::Document`], which dispatches by language
387    /// rather than by kind (OM.1).
388    ///
389    /// Returns `None` for a language no registered major claims —
390    /// including every built-in language today, since the built-in
391    /// majors still resolve through
392    /// `lattice_syntax::major_mode_id_for_lang`'s table and the host
393    /// consults that first. The index exists so a **plugin**
394    /// language, which can have no arm in a hand-written table, gets
395    /// a major at all.
396    ///
397    /// Index built at register-time, so lookup is `HashMap`-cheap.
398    pub fn find_major_for_lang(&self, lang: &str) -> Option<ModeId> {
399        self.lang_index.get(lang).copied()
400    }
401
402    /// The major that PRESENTS `path`'s file type without loading it as text,
403    /// if one claims that extension.
404    ///
405    /// The open path asks this BEFORE reading: a match means the bytes are
406    /// never read into a rope, which for a PNG is the difference between a
407    /// picture and a UTF-8 error.
408    pub fn presenting_major_for_path(&self, path: &std::path::Path) -> Option<ModeId> {
409        let ext = path.extension()?.to_str()?.to_ascii_lowercase();
410        self.presents_index.get(&ext).copied()
411    }
412
413    /// Iterate every registered mode's `(id, kind)`.
414    pub fn iter_meta(&self) -> impl Iterator<Item = (ModeId, ModeKind)> + '_ {
415        self.modes.iter().map(|(id, mode)| (*id, mode.kind()))
416    }
417
418    /// MA.1/MA.2: the minor modes whose declared
419    /// [`ActivationPolicy`](crate::ActivationPolicy) auto-activates
420    /// when a buffer of kind `buffer_kind` enters the major mode named
421    /// `major`. This is the core of the (B) host resolver
422    /// (mode-architecture.md §7.4): the host subscribes once to
423    /// [`lattice_protocol::Event::MajorEntered`] and activates each
424    /// minor this returns. O(registered minors) on a *rare* event
425    /// (buffer open / major switch), never per-keystroke.
426    ///
427    /// `buffer_kind` gates `Global` minors to real document buffers
428    /// (see [`ActivationPolicy::admits`](crate::ActivationPolicy::admits)).
429    ///
430    /// Reads each minor's *declared default* policy. The config fold
431    /// (`<mode>.activation`) is layered by the host before this is
432    /// consulted (SN.3); this method does not see config.
433    ///
434    /// Order is `HashMap`-undefined; callers that need determinism
435    /// sort the result.
436    pub fn auto_activatable_minors(&self, major: &str, buffer_kind: BufferKind) -> Vec<ModeId> {
437        self.modes
438            .iter()
439            .filter(|(_, mode)| mode.kind() == ModeKind::Minor)
440            .filter(|(_, mode)| mode.activation_policy().admits(major, buffer_kind))
441            // CI.3: enablement gates activation. Native modes are auto-enabled at
442            // registration (unchanged); a plugin minor stays inert until the user
443            // enables it (config-and-init.md §6).
444            .filter(|(id, _)| self.enabled.contains(*id))
445            .map(|(id, _)| *id)
446            .collect()
447    }
448
449    /// Iterate every registered mode as `(id, Arc<dyn DynMode>)`.
450    ///
451    /// K.2.4: the keymap-substrate translation pass walks the
452    /// registry to call `Mode::keymap()` on each mode and merge
453    /// the contributed bindings into the host's `KeymapHandle`.
454    /// `iter_meta` is enough when only `(id, kind)` matters;
455    /// this is the variant that hands back the live mode trait
456    /// object so consumers can dispatch trait methods.
457    ///
458    /// Order is `HashMap`-undefined; callers that care about
459    /// determinism sort the iterator themselves.
460    pub fn iter(&self) -> impl Iterator<Item = (ModeId, Arc<dyn DynMode>)> + '_ {
461        self.modes.iter().map(|(id, mode)| (*id, Arc::clone(mode)))
462    }
463
464    /// Number of registered modes, majors and minors, enabled or not.
465    pub fn len(&self) -> usize {
466        self.modes.len()
467    }
468
469    /// True when no mode is registered.
470    pub fn is_empty(&self) -> bool {
471        self.modes.is_empty()
472    }
473
474    /// Activate a major mode on `buffer`. Synchronous prefix:
475    /// validate the major + its `implies()` tree, mutate
476    /// `active_modes` for the whole tree, bump epochs + build a
477    /// cascade plan. Then one task is spawned that walks the
478    /// plan in DFS order, awaiting each step's
479    /// `on_activate.await` before the next.
480    ///
481    /// If a different major is currently active, it is
482    /// deactivated synchronously first (Drop runs, `MajorExiting`
483    /// publishes). Idempotent: reactivating the current major
484    /// triggers a *reload* (deactivate then re-activate).
485    #[allow(clippy::too_many_arguments)]
486    pub fn activate_major(
487        &self,
488        active: &mut ActiveModes,
489        guards: &GuardStoreHandle,
490        config: &Arc<lattice_config::ConfigRegistry>,
491        events: &Arc<lattice_runtime::EventBus>,
492        services: &Arc<crate::services::ServiceRegistry>,
493        buffer: BufferId,
494        mode: ModeId,
495        caps: CapabilitySet,
496    ) -> Result<(), ModeActivationError> {
497        let entry = self
498            .modes
499            .get(&mode)
500            .ok_or(ModeActivationError::NotRegistered(mode))?;
501        if entry.kind() != ModeKind::Major {
502            return Err(ModeActivationError::WrongKind { mode });
503        }
504        let missing = entry.required_capabilities() - caps;
505        if !missing.is_empty() {
506            return Err(ModeActivationError::MissingCapability { mode, missing });
507        }
508
509        // Tear down current major (if any). `MajorExiting`
510        // publishes BEFORE the Guard drops.
511        if let Some(prev_id) = active.major() {
512            events.publish(Event::MajorExiting {
513                buffer,
514                major: prev_id.as_str().to_string(),
515            });
516            let _ = guards.remove(buffer, prev_id);
517            active.set_major(None);
518        }
519
520        // Sync prefix: mutate active_modes BEFORE building the
521        // cascade plan so `App::active_modes.has_major(mode)`
522        // is `true` the moment this call returns.
523        active.set_major(Some(mode));
524
525        // Build the cascade plan: root major + implied minors
526        // in DFS order. Each step bumps the epoch + records
527        // the new value so the spawn task can validate before
528        // stashing. Validation errors short-circuit the build;
529        // partial active_modes mutation rolls back on error.
530        let mut plan: Vec<CascadeStep> = vec![CascadeStep {
531            entry: entry.clone(),
532            mode,
533            kind: ModeKind::Major,
534            epoch: guards.bump_epoch(buffer, mode),
535        }];
536        if let Err(e) =
537            self.record_implies_cascade(active, &mut plan, entry, mode, caps, buffer, guards)
538        {
539            active.set_major(None);
540            for step in plan.iter().skip(1) {
541                active.remove_minor(step.mode);
542            }
543            return Err(e);
544        }
545
546        self.spawn_cascade(
547            plan,
548            guards.clone(),
549            events.clone(),
550            config,
551            events,
552            services,
553            buffer,
554        );
555        Ok(())
556    }
557
558    /// Activate a minor mode on `buffer`. Same sync-prefix-then-
559    /// spawn shape as `activate_major`.
560    #[allow(clippy::too_many_arguments)]
561    pub fn activate_minor(
562        &self,
563        active: &mut ActiveModes,
564        guards: &GuardStoreHandle,
565        config: &Arc<lattice_config::ConfigRegistry>,
566        events: &Arc<lattice_runtime::EventBus>,
567        services: &Arc<crate::services::ServiceRegistry>,
568        buffer: BufferId,
569        mode: ModeId,
570        caps: CapabilitySet,
571    ) -> Result<(), ModeActivationError> {
572        let mut plan: Vec<CascadeStep> = Vec::new();
573        self.validate_and_record_minor(active, &mut plan, buffer, mode, caps, guards)?;
574        // `plan` is empty when the minor was already active (no
575        // -op): skip the spawn entirely.
576        if !plan.is_empty() {
577            self.spawn_cascade(
578                plan,
579                guards.clone(),
580                events.clone(),
581                config,
582                events,
583                services,
584                buffer,
585            );
586        }
587        Ok(())
588    }
589
590    /// Validate `mode` (a minor) against the active set, mutate
591    /// `active` to include it, bump its epoch, and push the
592    /// resulting [`CascadeStep`] onto `plan`. Recursively
593    /// records implied minors. On error, rolls back the
594    /// `active` mutations + plan pushes performed for THIS
595    /// call (callers responsible for unwinding their own
596    /// pushes).
597    #[allow(clippy::too_many_arguments)]
598    fn validate_and_record_minor(
599        &self,
600        active: &mut ActiveModes,
601        plan: &mut Vec<CascadeStep>,
602        buffer: BufferId,
603        mode: ModeId,
604        caps: CapabilitySet,
605        guards: &GuardStoreHandle,
606    ) -> Result<(), ModeActivationError> {
607        let entry = self
608            .modes
609            .get(&mode)
610            .ok_or(ModeActivationError::NotRegistered(mode))?;
611        if entry.kind() != ModeKind::Minor {
612            return Err(ModeActivationError::WrongKind { mode });
613        }
614        if active.has_minor(mode) {
615            return Ok(());
616        }
617        let missing = entry.required_capabilities() - caps;
618        if !missing.is_empty() {
619            return Err(ModeActivationError::MissingCapability { mode, missing });
620        }
621        // Conflict checks.
622        for &c in entry.conflicts_with() {
623            if active.is_active(c) {
624                return Err(ModeActivationError::Conflict { mode, active: c });
625            }
626        }
627        if let Some(major) = active.major()
628            && let Some(major_entry) = self.modes.get(&major)
629            && major_entry.conflicts_with().contains(&mode)
630        {
631            return Err(ModeActivationError::Conflict {
632                mode,
633                active: major,
634            });
635        }
636        for &active_minor in active.minors() {
637            if let Some(minor_entry) = self.modes.get(&active_minor)
638                && minor_entry.conflicts_with().contains(&mode)
639            {
640                return Err(ModeActivationError::Conflict {
641                    mode,
642                    active: active_minor,
643                });
644            }
645        }
646
647        active.push_minor(mode);
648        plan.push(CascadeStep {
649            entry: entry.clone(),
650            mode,
651            kind: ModeKind::Minor,
652            epoch: guards.bump_epoch(buffer, mode),
653        });
654
655        if let Err(e) = self.record_implies_cascade(active, plan, entry, mode, caps, buffer, guards)
656        {
657            active.remove_minor(mode);
658            if let Some(pos) = plan.iter().position(|s| s.mode == mode) {
659                for step in plan.drain(pos..) {
660                    if step.mode != mode {
661                        active.remove_minor(step.mode);
662                    }
663                }
664            }
665            return Err(e);
666        }
667
668        Ok(())
669    }
670
671    /// Walk `entry.implies()`, validating + recording each as
672    /// a cascade step. Shared between `activate_major` and the
673    /// minor recursion.
674    #[allow(clippy::too_many_arguments)]
675    fn record_implies_cascade(
676        &self,
677        active: &mut ActiveModes,
678        plan: &mut Vec<CascadeStep>,
679        entry: &Arc<dyn DynMode>,
680        mode: ModeId,
681        caps: CapabilitySet,
682        buffer: BufferId,
683        guards: &GuardStoreHandle,
684    ) -> Result<(), ModeActivationError> {
685        for &dep in entry.implies() {
686            if !self.is_registered(dep) {
687                return Err(ModeActivationError::UnregisteredDependency { mode, dep });
688            }
689            if active.has_minor(dep) {
690                continue;
691            }
692            self.validate_and_record_minor(active, plan, buffer, dep, caps, guards)?;
693        }
694        // RV.1 (2026-08-10): a mode that declares a refresh action pulls
695        // in `refreshable-view-mode`, which owns the shared `gr` chord.
696        // Folding this into the implies walk — rather than asking each
697        // mode to list the minor in `implies()` — is deliberate: a
698        // forgotten `implies()` entry would kill the chord exactly as
699        // silently as the three copied `gr` keymap entries this
700        // replaced. One line (`refresh_action`) is the whole contract.
701        //
702        // Not an error when the minor is unregistered: a test harness or
703        // a trimmed build may register a view's mode without it, and the
704        // chord simply does not bind. `debug!` rather than silence so it
705        // is diagnosable.
706        if entry.refresh_action().is_some() {
707            let shared = crate::RefreshableViewMode::mode_id();
708            if !active.has_minor(shared) && mode != shared {
709                if self.is_registered(shared) {
710                    self.validate_and_record_minor(active, plan, buffer, shared, caps, guards)?;
711                } else {
712                    tracing::debug!(
713                        %mode,
714                        "mode declares refresh_action but refreshable-view-mode is not registered; `gr` will not bind"
715                    );
716                }
717            }
718        }
719        // OA.4b: the same contract, one chord later. A mode that declares a
720        // fold-toggle action pulls in `foldable-view-mode`, which owns the
721        // shared `<Tab>` / `<S-Tab>`. Folded into the implies walk for the
722        // reason above — the copied chords this replaced (magit's and the
723        // agenda's) left four foldable views with no fold chord at all, and a
724        // forgotten `implies()` entry is how that stays true.
725        if entry.fold_toggle_action().is_some() {
726            let shared = crate::FoldableViewMode::mode_id();
727            if !active.has_minor(shared) && mode != shared {
728                if self.is_registered(shared) {
729                    self.validate_and_record_minor(active, plan, buffer, shared, caps, guards)?;
730                } else {
731                    tracing::debug!(
732                        %mode,
733                        "mode declares fold_toggle_action but foldable-view-mode is not registered; `<Tab>` will not bind"
734                    );
735                }
736            }
737        }
738        Ok(())
739    }
740
741    /// Deactivate a minor mode. Synchronous: locks `guards`,
742    /// removes + drops the Guard, publishes
743    /// `MinorDeactivated`. Idempotent.
744    ///
745    /// `MinorDeactivated` publishes BEFORE the Guard drops so
746    /// subscribers can inspect the state about to be torn down.
747    pub fn deactivate_minor(
748        &self,
749        active: &mut ActiveModes,
750        guards: &GuardStoreHandle,
751        events: &Arc<lattice_runtime::EventBus>,
752        buffer: BufferId,
753        mode: ModeId,
754    ) -> Result<(), ModeActivationError> {
755        if !active.has_minor(mode) {
756            return Ok(());
757        }
758        let entry = self
759            .modes
760            .get(&mode)
761            .ok_or(ModeActivationError::NotRegistered(mode))?;
762        let implies: Vec<ModeId> = entry.implies().to_vec();
763        events.publish(Event::MinorDeactivated {
764            buffer,
765            minor: mode.as_str().to_string(),
766        });
767        // Drop the Guard. Box<dyn Any + Send> goes out of scope
768        // *after* the lock releases (the `let _ = ...` binding
769        // owns it briefly).
770        let _ = guards.remove(buffer, mode);
771        active.remove_minor(mode);
772        // Cascade-deactivate every implied minor that's still
773        // active.
774        for &dep in &implies {
775            if !active.has_minor(dep) {
776                continue;
777            }
778            self.deactivate_minor(active, guards, events, buffer, dep)?;
779        }
780        Ok(())
781    }
782
783    /// Deactivate the active major mode (if any). Synchronous.
784    pub fn deactivate_major(
785        &self,
786        active: &mut ActiveModes,
787        guards: &GuardStoreHandle,
788        events: &Arc<lattice_runtime::EventBus>,
789        buffer: BufferId,
790    ) -> Result<(), ModeActivationError> {
791        let Some(mode) = active.major() else {
792            return Ok(());
793        };
794        let _ = self
795            .modes
796            .get(&mode)
797            .ok_or(ModeActivationError::NotRegistered(mode))?;
798        events.publish(Event::MajorExiting {
799            buffer,
800            major: mode.as_str().to_string(),
801        });
802        let _ = guards.remove(buffer, mode);
803        active.set_major(None);
804        Ok(())
805    }
806
807    /// Drive `plan` DFS. Builds the cascade future, polls it
808    /// once synchronously with a no-op waker, and only
809    /// `spawn_task`s the remaining work if the future is still
810    /// Pending.
811    ///
812    /// **Why try-sync-then-spawn:** today's modes (markers with
813    /// `Guard = ()`, plus the hand-written LSP modes) finish
814    /// `on_activate` without any `.await` -- the future returns
815    /// `Ready` on first poll. Letting the App thread drive the
816    /// cascade synchronously when nothing yields keeps
817    /// `App::activate_mode_by_id` ⇒ `App::deactivate_mode_by_id`
818    /// (rapid toggle, e.g. tests + future plugin scripting)
819    /// race-free: the Guard is in the store before deactivate
820    /// runs. When a mode is rewritten to `.await` real I/O
821    /// (e.g. the LSP initialize handshake), the first
822    /// `Pending` yield trips the spawn path and the
823    /// remainder runs on the runtime -- the App thread stops
824    /// blocking, paramount goal #4 still honoured.
825    ///
826    /// On success, stashes the Guard + publishes the
827    /// lifecycle event. On failure, publishes
828    /// `ModeActivationFailed` for the failing step plus a
829    /// synthetic "cascade aborted" failure for every
830    /// remaining unrun step, so the App's rollback subscriber
831    /// can clean up `active_modes` for the whole subtree.
832    #[allow(clippy::too_many_arguments)]
833    fn spawn_cascade(
834        &self,
835        plan: Vec<CascadeStep>,
836        guards: GuardStoreHandle,
837        events_for_task: Arc<lattice_runtime::EventBus>,
838        config: &Arc<lattice_config::ConfigRegistry>,
839        events: &Arc<lattice_runtime::EventBus>,
840        services: &Arc<crate::services::ServiceRegistry>,
841        buffer: BufferId,
842    ) {
843        let config = config.clone();
844        let events_ctx = events.clone();
845        let services = services.clone();
846        let cascade_fut = async move {
847            for (i, step) in plan.iter().enumerate() {
848                let ctx = ModeContext::new(
849                    buffer,
850                    step.mode,
851                    config.clone(),
852                    events_ctx.clone(),
853                    services.clone(),
854                );
855                match step.entry.on_activate_dyn(ctx).await {
856                    Ok(guard) => {
857                        // try_insert validates that no
858                        // deactivate (or later activate)
859                        // arrived during the await. On stale,
860                        // returns Err(guard) -- we drop the
861                        // Box here (which fires the original
862                        // Guard type's Drop for out-of-band
863                        // cleanup) and skip the success event.
864                        match guards.try_insert(buffer, step.mode, step.epoch, guard) {
865                            Ok(()) => {
866                                // MA.1: the observable lifecycle quartet
867                                // rides the `Event` enum (filterable by
868                                // `major_modes`; hookable). Internal
869                                // failure / conflict signals are the
870                                // only ones left on the typed bus.
871                                let name = step.mode.as_str().to_string();
872                                match step.kind {
873                                    ModeKind::Major => {
874                                        events_for_task.publish(Event::MajorEntered {
875                                            buffer,
876                                            major: name,
877                                        })
878                                    }
879                                    ModeKind::Minor => {
880                                        events_for_task.publish(Event::MinorActivated {
881                                            buffer,
882                                            minor: name,
883                                        })
884                                    }
885                                }
886                            }
887                            Err(stale_guard) => {
888                                // Drop here. The Box goes out
889                                // of scope on the next line;
890                                // the original Guard's Drop
891                                // fires (publishes
892                                // LspBufferDetached, restores
893                                // foldmethod, etc.).
894                                drop(stale_guard);
895                                // No event published: the
896                                // deactivate/re-activate that
897                                // bumped the epoch already
898                                // published its own
899                                // MinorDeactivated /
900                                // MajorExiting.
901                            }
902                        }
903                    }
904                    Err(err) => {
905                        events_for_task
906                            .publish_typed(ModeEvent::activation_failed(buffer, step.mode, &err));
907                        let trigger = step.mode;
908                        for remaining in &plan[i + 1..] {
909                            events_for_task.publish_typed(ModeEvent::ModeActivationFailed {
910                                buffer,
911                                mode: remaining.mode,
912                                reason: format!("cascade aborted by {trigger}"),
913                            });
914                        }
915                        return;
916                    }
917                }
918            }
919        };
920
921        // Try-sync-then-spawn. Poll once with a no-op waker
922        // (the future may register interest with it; standard
923        // Rust futures re-register on every poll, so handing
924        // the same `fut` to tokio when Pending is sound).
925        let mut fut: std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> =
926            Box::pin(cascade_fut);
927        let waker = std::task::Waker::noop();
928        let mut cx = std::task::Context::from_waker(waker);
929        match fut.as_mut().poll(&mut cx) {
930            std::task::Poll::Ready(()) => {
931                // Cascade finished entirely on this thread; no
932                // spawn needed. Guards are in the store, events
933                // are on the bus. Rapid `deactivate` is now
934                // race-free.
935            }
936            std::task::Poll::Pending => {
937                lattice_runtime::spawn_task(fut);
938            }
939        }
940    }
941}
942
943/// One step in a cascade plan. Built synchronously by the sync
944/// prefix; consumed by the spawned task that awaits each step's
945/// `on_activate.await` in order.
946///
947/// `epoch` is the value [`GuardStoreHandle::bump_epoch`]
948/// returned when the sync prefix queued this step. The spawn
949/// task passes it back to [`GuardStoreHandle::try_insert`] on
950/// completion; mismatch means a deactivate (or a later
951/// re-activate) bumped the epoch meanwhile, so the Guard is
952/// stale and gets dropped instead of stashed.
953struct CascadeStep {
954    entry: Arc<dyn DynMode>,
955    mode: ModeId,
956    kind: ModeKind,
957    epoch: u64,
958}
959
960#[cfg(test)]
961mod tests {
962    #![allow(clippy::unwrap_used, clippy::panic)]
963    use super::*;
964    use crate::mode::LifecycleFuture;
965    use std::sync::Arc as StdArc;
966    use std::sync::atomic::{AtomicU32, Ordering};
967    use tokio::sync::mpsc::UnboundedReceiver;
968
969    /// Test mode with a typed Guard that records drop count.
970    struct MockMode {
971        id: ModeId,
972        kind: ModeKind,
973        required: CapabilitySet,
974        conflicts: Vec<ModeId>,
975        implies: Vec<ModeId>,
976        target_kind: Option<BufferKind>,
977        target_lang: Option<String>,
978        policy: crate::ActivationPolicy,
979        refresh: Option<&'static str>,
980        activate_calls: StdArc<AtomicU32>,
981        deactivate_calls: StdArc<AtomicU32>,
982    }
983
984    struct MockGuard {
985        deactivate_calls: StdArc<AtomicU32>,
986    }
987
988    impl Drop for MockGuard {
989        fn drop(&mut self) {
990            self.deactivate_calls.fetch_add(1, Ordering::SeqCst);
991        }
992    }
993
994    impl MockMode {
995        fn major(name: &str) -> Self {
996            Self {
997                id: ModeId::new(name),
998                kind: ModeKind::Major,
999                required: CapabilitySet::empty(),
1000                conflicts: Vec::new(),
1001                implies: Vec::new(),
1002                target_kind: None,
1003                target_lang: None,
1004                policy: crate::ActivationPolicy::Manual,
1005                refresh: None,
1006                activate_calls: StdArc::new(AtomicU32::new(0)),
1007                deactivate_calls: StdArc::new(AtomicU32::new(0)),
1008            }
1009        }
1010        fn targeting(mut self, kind: BufferKind) -> Self {
1011            self.target_kind = Some(kind);
1012            self
1013        }
1014        /// OM.1: declare this mode the major for a language.
1015        fn for_lang(mut self, lang: &str) -> Self {
1016            self.target_lang = Some(lang.to_string());
1017            self
1018        }
1019        fn minor(name: &str) -> Self {
1020            Self {
1021                id: ModeId::new(name),
1022                kind: ModeKind::Minor,
1023                required: CapabilitySet::empty(),
1024                conflicts: Vec::new(),
1025                implies: Vec::new(),
1026                target_kind: None,
1027                target_lang: None,
1028                policy: crate::ActivationPolicy::Manual,
1029                refresh: None,
1030                activate_calls: StdArc::new(AtomicU32::new(0)),
1031                deactivate_calls: StdArc::new(AtomicU32::new(0)),
1032            }
1033        }
1034        fn requires(mut self, caps: CapabilitySet) -> Self {
1035            self.required = caps;
1036            self
1037        }
1038        fn conflicting_with(mut self, other: ModeId) -> Self {
1039            self.conflicts.push(other);
1040            self
1041        }
1042        fn implying(mut self, other: ModeId) -> Self {
1043            self.implies.push(other);
1044            self
1045        }
1046        fn with_policy(mut self, policy: crate::ActivationPolicy) -> Self {
1047            self.policy = policy;
1048            self
1049        }
1050        /// RV.1: declare a refresh action, which should pull
1051        /// `refreshable-view-mode` in through the implies cascade.
1052        fn refreshing(mut self, action: &'static str) -> Self {
1053            self.refresh = Some(action);
1054            self
1055        }
1056    }
1057
1058    impl Mode for MockMode {
1059        type Guard = MockGuard;
1060        fn id(&self) -> ModeId {
1061            self.id
1062        }
1063        fn kind(&self) -> ModeKind {
1064            self.kind
1065        }
1066        fn target_buffer_kind(&self) -> Option<BufferKind> {
1067            self.target_kind
1068        }
1069        fn target_language(&self) -> Option<&str> {
1070            self.target_lang.as_deref()
1071        }
1072        fn required_capabilities(&self) -> CapabilitySet {
1073            self.required
1074        }
1075        fn conflicts_with(&self) -> &[ModeId] {
1076            &self.conflicts
1077        }
1078        fn implies(&self) -> &[ModeId] {
1079            &self.implies
1080        }
1081        fn activation_policy(&self) -> crate::ActivationPolicy {
1082            self.policy.clone()
1083        }
1084        fn refresh_action(&self) -> Option<&'static str> {
1085            self.refresh
1086        }
1087        fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
1088            self.activate_calls.fetch_add(1, Ordering::SeqCst);
1089            let deact = self.deactivate_calls.clone();
1090            Box::pin(async move {
1091                Ok(MockGuard {
1092                    deactivate_calls: deact,
1093                })
1094            })
1095        }
1096    }
1097
1098    fn buf() -> BufferId {
1099        BufferId::new(1)
1100    }
1101
1102    fn cfg() -> Arc<lattice_config::ConfigRegistry> {
1103        Arc::new(lattice_config::ConfigRegistry::new())
1104    }
1105
1106    fn evts() -> Arc<lattice_runtime::EventBus> {
1107        Arc::new(lattice_runtime::EventBus::new())
1108    }
1109
1110    fn svcs() -> Arc<crate::services::ServiceRegistry> {
1111        Arc::new(crate::services::ServiceRegistry::new())
1112    }
1113
1114    /// Subscribe to the internal typed `ModeEvent` signals
1115    /// (`ModeActivationFailed` / `OptionConflict`) on `bus`. Used by
1116    /// the failure-path tests; the observable lifecycle quartet is on
1117    /// the `Event` enum (see [`subscribe_lifecycle`]).
1118    fn subscribe_mode_events(bus: &lattice_runtime::EventBus) -> UnboundedReceiver<ModeEvent> {
1119        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1120        bus.subscribe_typed::<ModeEvent>(tx);
1121        rx
1122    }
1123
1124    /// Drain a typed `ModeEvent` (failure-path tests).
1125    async fn await_event(rx: &mut UnboundedReceiver<ModeEvent>) -> ModeEvent {
1126        rx.recv()
1127            .await
1128            .expect("bus channel should deliver the event")
1129    }
1130
1131    /// MA.1: subscribe to the observable mode-lifecycle quartet on the
1132    /// `Event` enum bus (`MajorEntered` / `MajorExiting` /
1133    /// `MinorActivated` / `MinorDeactivated`). They moved off the typed
1134    /// `ModeEvent` path so hooks + the EF.1 filter apply.
1135    fn subscribe_lifecycle(
1136        bus: &lattice_runtime::EventBus,
1137    ) -> UnboundedReceiver<lattice_protocol::Event> {
1138        use lattice_protocol::EventKind;
1139        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
1140        bus.subscribe(
1141            lattice_runtime::EventFilter::kinds(vec![
1142                EventKind::MajorEntered,
1143                EventKind::MajorExiting,
1144                EventKind::MinorActivated,
1145                EventKind::MinorDeactivated,
1146            ]),
1147            lattice_runtime::SubscriptionTarget::Channel(tx),
1148        );
1149        rx
1150    }
1151
1152    /// Drain a lifecycle `Event` (yields to the runtime until the
1153    /// spawned cascade task publishes).
1154    async fn await_lifecycle(
1155        rx: &mut UnboundedReceiver<lattice_protocol::Event>,
1156    ) -> lattice_protocol::Event {
1157        rx.recv()
1158            .await
1159            .expect("bus channel should deliver the lifecycle event")
1160    }
1161
1162    #[test]
1163    fn find_major_for_kind_returns_registered_mode() {
1164        let mut r = ModeRegistry::new();
1165        let id = r
1166            .register(MockMode::major("ft-mode").targeting(BufferKind::FileTree))
1167            .unwrap();
1168        assert_eq!(r.find_major_for_kind(BufferKind::FileTree), Some(id));
1169    }
1170
1171    #[test]
1172    fn activation_policy_admits_matches_expected_majors() {
1173        use crate::ActivationPolicy;
1174        let doc = BufferKind::Document;
1175        assert!(!ActivationPolicy::Manual.admits("rust-mode", doc));
1176        // Global: every major, but only in real document buffers.
1177        assert!(ActivationPolicy::Global.admits("rust-mode", doc));
1178        assert!(ActivationPolicy::Global.admits("python-mode", doc));
1179        assert!(
1180            !ActivationPolicy::Global.admits("help-mode", BufferKind::Help),
1181            "Global is scoped to document buffers — never fires in synthetic UI buffers"
1182        );
1183        // Majors allowlist: kind-independent (explicit opt-in works
1184        // even inside a synthetic buffer's major).
1185        let allow = ActivationPolicy::Majors(vec![ModeId::new("rust-mode")]);
1186        assert!(allow.admits("rust-mode", doc));
1187        assert!(!allow.admits("python-mode", doc));
1188        assert!(
1189            ActivationPolicy::Majors(vec![ModeId::new("help-mode")])
1190                .admits("help-mode", BufferKind::Help),
1191            "an explicit major allowlist activates inside that major regardless of kind"
1192        );
1193        // An empty allowlist matches nothing (Manual-equivalent).
1194        assert!(!ActivationPolicy::Majors(vec![]).admits("rust-mode", doc));
1195        // Universal: every kind, document AND synthetic UI buffers —
1196        // the scope a universal leader (emacs-keys) needs.
1197        for kind in [
1198            BufferKind::Document,
1199            BufferKind::Messages,
1200            BufferKind::Help,
1201            BufferKind::FileTree,
1202            BufferKind::Oil,
1203            BufferKind::Terminal,
1204        ] {
1205            assert!(
1206                ActivationPolicy::Universal.admits("any-mode", kind),
1207                "Universal must admit {kind:?}"
1208            );
1209        }
1210    }
1211
1212    #[test]
1213    fn register_rejects_mode_id_without_mode_suffix() {
1214        // The `-mode` naming convention (mode_id.rs) is enforced at the
1215        // registration choke point: a missing suffix is rejected for
1216        // every mode, so the `emacs-keys` (no suffix) slip can't recur.
1217        let mut r = ModeRegistry::new();
1218        let err = r.register(MockMode::minor("emacs-keys")).unwrap_err();
1219        assert_eq!(
1220            err,
1221            RegistrationError::MissingModeSuffix(ModeId::new("emacs-keys"))
1222        );
1223        // The suffixed form registers fine.
1224        assert!(r.register(MockMode::minor("emacs-keys-mode")).is_ok());
1225    }
1226
1227    #[test]
1228    fn auto_activatable_minors_filters_by_policy_and_kind() {
1229        use crate::ActivationPolicy;
1230        let mut r = ModeRegistry::new();
1231        // A major with a Global policy must NOT be returned (only
1232        // minors auto-activate via this path).
1233        r.register(MockMode::major("rust-mode").with_policy(ActivationPolicy::Global))
1234            .unwrap();
1235        // Manual minor: never auto-activates.
1236        r.register(MockMode::minor("manual-mode")).unwrap();
1237        // Global minor: activates for every major.
1238        let global = r
1239            .register(MockMode::minor("global-mode").with_policy(ActivationPolicy::Global))
1240            .unwrap();
1241        // Allowlisted minor: only for rust-mode.
1242        let rusty = r
1243            .register(
1244                MockMode::minor("rusty-mode")
1245                    .with_policy(ActivationPolicy::Majors(vec![ModeId::new("rust-mode")])),
1246            )
1247            .unwrap();
1248
1249        let mut for_rust = r.auto_activatable_minors("rust-mode", BufferKind::Document);
1250        for_rust.sort();
1251        let mut expected = vec![global, rusty];
1252        expected.sort();
1253        assert_eq!(
1254            for_rust, expected,
1255            "global + allowlisted minors fire for a rust document"
1256        );
1257
1258        // For a non-allowlisted major only the Global minor fires.
1259        assert_eq!(
1260            r.auto_activatable_minors("python-mode", BufferKind::Document),
1261            vec![global],
1262            "only the global minor fires for a python document"
1263        );
1264
1265        // MA.2: in a synthetic buffer (Help) the Global minor is
1266        // gated out; only an explicit major allowlist would fire (none
1267        // here targets help-mode), so nothing activates.
1268        assert!(
1269            r.auto_activatable_minors("help-mode", BufferKind::Help)
1270                .is_empty(),
1271            "Global minors must not auto-activate in synthetic buffers"
1272        );
1273    }
1274
1275    #[test]
1276    fn plugin_minor_is_inert_until_enabled() {
1277        use crate::ActivationPolicy;
1278        let mut r = ModeRegistry::new();
1279
1280        // Native (register): a Global minor auto-activates immediately.
1281        let native = r
1282            .register(MockMode::minor("native-mode").with_policy(ActivationPolicy::Global))
1283            .unwrap();
1284        // Plugin (register_available): a Global minor is registered but INERT.
1285        let plugin = r
1286            .register_available(
1287                MockMode::minor("plugin-mode").with_policy(ActivationPolicy::Global),
1288            )
1289            .unwrap();
1290
1291        assert!(
1292            r.is_registered(plugin),
1293            "plugin mode is available (registered)"
1294        );
1295        assert!(
1296            !r.is_minor_enabled(&plugin),
1297            "plugin mode is NOT enabled at registration"
1298        );
1299        assert!(r.is_minor_enabled(&native), "native mode is auto-enabled");
1300
1301        // Only the native minor fires — the plugin minor is gated out.
1302        assert_eq!(
1303            r.auto_activatable_minors("rust-mode", BufferKind::Document),
1304            vec![native],
1305            "the plugin minor does not auto-activate until enabled"
1306        );
1307
1308        // Enable the plugin minor → now both fire.
1309        r.set_minor_enabled(plugin, true);
1310        let mut got = r.auto_activatable_minors("rust-mode", BufferKind::Document);
1311        got.sort();
1312        let mut expected = vec![native, plugin];
1313        expected.sort();
1314        assert_eq!(got, expected, "an enabled plugin minor auto-activates");
1315
1316        // Disable it → back to inert.
1317        r.set_minor_enabled(plugin, false);
1318        assert_eq!(
1319            r.auto_activatable_minors("rust-mode", BufferKind::Document),
1320            vec![native],
1321            "disabling removes the plugin minor from activation"
1322        );
1323    }
1324
1325    // ── OM.1: the language index ──────────────────────────────────
1326
1327    #[test]
1328    fn find_major_for_lang_resolves_a_declared_claim() {
1329        let mut r = ModeRegistry::new();
1330        let org = r
1331            .register(MockMode::major("org-mode").for_lang("org"))
1332            .unwrap();
1333        assert_eq!(r.find_major_for_lang("org"), Some(org));
1334    }
1335
1336    #[test]
1337    fn find_major_for_lang_returns_none_when_unclaimed() {
1338        let mut r = ModeRegistry::new();
1339        r.register(MockMode::major("org-mode").for_lang("org"))
1340            .unwrap();
1341        // A language nothing claims resolves to nothing — the caller
1342        // falls through to `text-mode`, exactly as before this index.
1343        assert_eq!(r.find_major_for_lang("rust"), None);
1344        // And a registry with no claims at all is simply empty.
1345        assert_eq!(ModeRegistry::new().find_major_for_lang("org"), None);
1346    }
1347
1348    #[test]
1349    fn find_major_for_lang_keeps_first_registration_when_clobbered() {
1350        // Two majors claim "org". First wins; the second warns rather
1351        // than erroring, so boot / load order is not load-bearing for
1352        // correctness — the `target_buffer_kind` contract.
1353        let mut r = ModeRegistry::new();
1354        let first = r
1355            .register(MockMode::major("org-a-mode").for_lang("org"))
1356            .unwrap();
1357        let _second = r
1358            .register(MockMode::major("org-b-mode").for_lang("org"))
1359            .unwrap();
1360        assert_eq!(r.find_major_for_lang("org"), Some(first));
1361    }
1362
1363    #[test]
1364    fn a_minor_claiming_a_language_is_not_indexed() {
1365        // A buffer has exactly one major. Indexing a minor here would
1366        // install it AS the major on every buffer of that language.
1367        // Reachable from plugin input, so it is refused rather than
1368        // respected.
1369        let mut r = ModeRegistry::new();
1370        r.register(MockMode::minor("org-todo-mode").for_lang("org"))
1371            .unwrap();
1372        assert_eq!(r.find_major_for_lang("org"), None);
1373    }
1374
1375    #[test]
1376    fn unregister_frees_the_language_claim() {
1377        // A plugin's major is unregistered on unload; a stale claim
1378        // would resolve documents onto a mode that no longer exists.
1379        let mut r = ModeRegistry::new();
1380        let org = r
1381            .register(MockMode::major("org-mode").for_lang("org"))
1382            .unwrap();
1383        assert_eq!(r.find_major_for_lang("org"), Some(org));
1384
1385        assert!(r.unregister(org));
1386        assert_eq!(r.find_major_for_lang("org"), None);
1387
1388        // And the freed language can be claimed again — a reload must
1389        // not be poisoned by the previous load.
1390        let reloaded = r
1391            .register(MockMode::major("org-mode").for_lang("org"))
1392            .unwrap();
1393        assert_eq!(r.find_major_for_lang("org"), Some(reloaded));
1394    }
1395
1396    #[test]
1397    fn kind_and_language_claims_are_independent() {
1398        // A major may own a kind, a language, both, or neither — the
1399        // two indexes never consult each other (`markdown-mode` is the
1400        // real both-case: BufferKind::Help *and* Lang::Markdown).
1401        let mut r = ModeRegistry::new();
1402        let both = r
1403            .register(
1404                MockMode::major("markdownish-mode")
1405                    .targeting(BufferKind::Help)
1406                    .for_lang("markdown"),
1407            )
1408            .unwrap();
1409        assert_eq!(r.find_major_for_kind(BufferKind::Help), Some(both));
1410        assert_eq!(r.find_major_for_lang("markdown"), Some(both));
1411
1412        assert!(r.unregister(both));
1413        assert_eq!(r.find_major_for_kind(BufferKind::Help), None);
1414        assert_eq!(r.find_major_for_lang("markdown"), None);
1415    }
1416
1417    #[test]
1418    fn find_major_for_kind_returns_none_when_unbound() {
1419        let mut r = ModeRegistry::new();
1420        r.register(MockMode::major("plain-mode")).unwrap();
1421        // No mode declared `target_buffer_kind`, so the index is
1422        // empty for every kind.
1423        assert_eq!(r.find_major_for_kind(BufferKind::Oil), None);
1424    }
1425
1426    #[test]
1427    fn find_major_for_kind_keeps_first_registration_when_clobbered() {
1428        // Two modes both claim BufferKind::Oil. The first wins;
1429        // the second is logged (no panic, no error) so deterministic
1430        // boot order isn't load-bearing for correctness.
1431        let mut r = ModeRegistry::new();
1432        let first = r
1433            .register(MockMode::major("oil-a-mode").targeting(BufferKind::Oil))
1434            .unwrap();
1435        let _second = r
1436            .register(MockMode::major("oil-b-mode").targeting(BufferKind::Oil))
1437            .unwrap();
1438        assert_eq!(r.find_major_for_kind(BufferKind::Oil), Some(first));
1439    }
1440
1441    #[test]
1442    fn find_major_for_kind_ignores_minor_target() {
1443        // Minor modes never own a kind. Even if one declares a
1444        // target_buffer_kind by mistake, treating it as the major
1445        // would violate the kind contract — but we don't filter
1446        // by ModeKind, we just respect whatever the mode declares.
1447        // A test for the *common* case: a minor with no target is
1448        // not indexed.
1449        let mut r = ModeRegistry::new();
1450        r.register(MockMode::minor("a-minor-mode")).unwrap();
1451        assert_eq!(r.find_major_for_kind(BufferKind::Document), None);
1452    }
1453
1454    #[test]
1455    fn unregister_removes_mode_and_frees_its_kind_claim() {
1456        let mut r = ModeRegistry::new();
1457        let major = r
1458            .register(MockMode::major("rust-mode").targeting(BufferKind::Document))
1459            .unwrap();
1460        let minor = r.register(MockMode::minor("keep-mode")).unwrap();
1461        assert_eq!(r.find_major_for_kind(BufferKind::Document), Some(major));
1462
1463        // Unregister the major: it's gone, its kind claim is freed, the other
1464        // mode is untouched.
1465        assert!(r.unregister(major));
1466        assert!(!r.is_registered(major));
1467        assert!(r.get(major).is_none());
1468        assert_eq!(r.find_major_for_kind(BufferKind::Document), None);
1469        assert!(r.is_registered(minor));
1470
1471        // Idempotent: a second unload of an already-removed id is a no-op.
1472        // (Checked before the reload below — `ModeId` is interned by name, so a
1473        // re-registered `"rust-mode"` is the *same* id and would be removable.)
1474        assert!(!r.unregister(major));
1475
1476        // The freed kind can be re-claimed by a fresh registration (the reload
1477        // case): register-again no longer hits Duplicate and re-owns the kind.
1478        let reloaded = r
1479            .register(MockMode::major("rust-mode").targeting(BufferKind::Document))
1480            .unwrap();
1481        assert_eq!(reloaded, major, "interned id is stable across reload");
1482        assert_eq!(r.find_major_for_kind(BufferKind::Document), Some(reloaded));
1483    }
1484
1485    #[tokio::test]
1486    async fn register_and_lookup() {
1487        let mut r = ModeRegistry::new();
1488        let id = r.register(MockMode::major("rust-mode")).unwrap();
1489        assert!(r.is_registered(id));
1490        assert_eq!(r.get(id).unwrap().kind(), ModeKind::Major);
1491        assert_eq!(r.len(), 1);
1492    }
1493
1494    #[tokio::test]
1495    async fn duplicate_registration_fails() {
1496        let mut r = ModeRegistry::new();
1497        r.register(MockMode::major("rust-mode")).unwrap();
1498        let err = r.register(MockMode::major("rust-mode")).unwrap_err();
1499        assert!(matches!(err, RegistrationError::Duplicate(_)));
1500    }
1501
1502    #[tokio::test]
1503    async fn activate_unregistered_major_fails() {
1504        let r = ModeRegistry::new();
1505        let mut a = ActiveModes::new();
1506        let g = GuardStoreHandle::new();
1507        let err = r
1508            .activate_major(
1509                &mut a,
1510                &g,
1511                &cfg(),
1512                &evts(),
1513                &svcs(),
1514                buf(),
1515                ModeId::new("ghost-mode"),
1516                CapabilitySet::empty(),
1517            )
1518            .unwrap_err();
1519        assert!(matches!(err, ModeActivationError::NotRegistered(_)));
1520    }
1521
1522    #[tokio::test]
1523    async fn activate_wrong_kind_fails() {
1524        let mut r = ModeRegistry::new();
1525        let id = r.register(MockMode::minor("read-only-mode")).unwrap();
1526        let mut a = ActiveModes::new();
1527        let g = GuardStoreHandle::new();
1528        let err = r
1529            .activate_major(
1530                &mut a,
1531                &g,
1532                &cfg(),
1533                &evts(),
1534                &svcs(),
1535                buf(),
1536                id,
1537                CapabilitySet::empty(),
1538            )
1539            .unwrap_err();
1540        assert!(matches!(err, ModeActivationError::WrongKind { .. }));
1541    }
1542
1543    #[tokio::test]
1544    async fn missing_capability_blocks_activation() {
1545        let mut r = ModeRegistry::new();
1546        let id = r
1547            .register(MockMode::minor("lsp-mode").requires(CapabilitySet::LSP))
1548            .unwrap();
1549        let mut a = ActiveModes::new();
1550        let g = GuardStoreHandle::new();
1551        let err = r
1552            .activate_minor(
1553                &mut a,
1554                &g,
1555                &cfg(),
1556                &evts(),
1557                &svcs(),
1558                buf(),
1559                id,
1560                CapabilitySet::empty(),
1561            )
1562            .unwrap_err();
1563        match err {
1564            ModeActivationError::MissingCapability { mode, missing } => {
1565                assert_eq!(mode, id);
1566                assert_eq!(missing, CapabilitySet::LSP);
1567            }
1568            _ => panic!("expected MissingCapability"),
1569        }
1570    }
1571
1572    #[tokio::test]
1573    async fn major_activation_publishes_entered_and_stashes_guard() {
1574        let mock = MockMode::major("rust-mode");
1575        let act = mock.activate_calls.clone();
1576        let mut r = ModeRegistry::new();
1577        let id = r.register(mock).unwrap();
1578        let mut a = ActiveModes::new();
1579        let g = GuardStoreHandle::new();
1580        let bus = evts();
1581        let mut rx = subscribe_lifecycle(&bus);
1582        r.activate_major(
1583            &mut a,
1584            &g,
1585            &cfg(),
1586            &bus,
1587            &svcs(),
1588            buf(),
1589            id,
1590            CapabilitySet::empty(),
1591        )
1592        .unwrap();
1593        // Sync prefix mutated `active_modes` immediately.
1594        assert_eq!(a.major(), Some(id));
1595        // Lifecycle task publishes MajorEntered when on_activate
1596        // resolves.
1597        let evt = await_lifecycle(&mut rx).await;
1598        assert!(matches!(evt, Event::MajorEntered { major, .. } if major == id.as_str()));
1599        assert_eq!(act.load(Ordering::SeqCst), 1);
1600        assert!(g.contains(buf(), id), "Guard stashed by spawned task");
1601    }
1602
1603    #[tokio::test]
1604    async fn major_swap_publishes_exiting_then_entered() {
1605        let prev = MockMode::major("text-mode");
1606        let new = MockMode::major("rust-mode");
1607        let prev_deact = prev.deactivate_calls.clone();
1608        let new_act = new.activate_calls.clone();
1609        let mut r = ModeRegistry::new();
1610        let prev_id = r.register(prev).unwrap();
1611        let new_id = r.register(new).unwrap();
1612        let mut a = ActiveModes::new();
1613        let g = GuardStoreHandle::new();
1614        let bus = evts();
1615        let mut rx = subscribe_lifecycle(&bus);
1616        // First major: drain the MajorEntered.
1617        r.activate_major(
1618            &mut a,
1619            &g,
1620            &cfg(),
1621            &bus,
1622            &svcs(),
1623            buf(),
1624            prev_id,
1625            CapabilitySet::empty(),
1626        )
1627        .unwrap();
1628        let evt = await_lifecycle(&mut rx).await;
1629        assert!(matches!(evt, Event::MajorEntered { major, .. } if major == prev_id.as_str()));
1630        // Swap.
1631        r.activate_major(
1632            &mut a,
1633            &g,
1634            &cfg(),
1635            &bus,
1636            &svcs(),
1637            buf(),
1638            new_id,
1639            CapabilitySet::empty(),
1640        )
1641        .unwrap();
1642        // MajorExiting fires synchronously; MajorEntered after
1643        // the spawned task resolves.
1644        let exiting = await_lifecycle(&mut rx).await;
1645        assert!(matches!(exiting, Event::MajorExiting { major, .. } if major == prev_id.as_str()));
1646        let entered = await_lifecycle(&mut rx).await;
1647        assert!(matches!(entered, Event::MajorEntered { major, .. } if major == new_id.as_str()));
1648        // Previous Guard's Drop ran (Drop = cleanup contract).
1649        assert_eq!(prev_deact.load(Ordering::SeqCst), 1);
1650        assert_eq!(new_act.load(Ordering::SeqCst), 1);
1651        assert_eq!(a.major(), Some(new_id));
1652        assert!(g.contains(buf(), new_id));
1653        assert!(!g.contains(buf(), prev_id));
1654    }
1655
1656    #[tokio::test]
1657    async fn major_reload_drops_then_reactivates() {
1658        let mock = MockMode::major("rust-mode");
1659        let act = mock.activate_calls.clone();
1660        let deact = mock.deactivate_calls.clone();
1661        let mut r = ModeRegistry::new();
1662        let id = r.register(mock).unwrap();
1663        let mut a = ActiveModes::new();
1664        let g = GuardStoreHandle::new();
1665        let bus = evts();
1666        let mut rx = subscribe_lifecycle(&bus);
1667        r.activate_major(
1668            &mut a,
1669            &g,
1670            &cfg(),
1671            &bus,
1672            &svcs(),
1673            buf(),
1674            id,
1675            CapabilitySet::empty(),
1676        )
1677        .unwrap();
1678        let _entered = await_lifecycle(&mut rx).await;
1679        r.activate_major(
1680            &mut a,
1681            &g,
1682            &cfg(),
1683            &bus,
1684            &svcs(),
1685            buf(),
1686            id,
1687            CapabilitySet::empty(),
1688        )
1689        .unwrap();
1690        let _exiting = await_lifecycle(&mut rx).await;
1691        let _re_entered = await_lifecycle(&mut rx).await;
1692        assert_eq!(act.load(Ordering::SeqCst), 2);
1693        assert_eq!(deact.load(Ordering::SeqCst), 1);
1694    }
1695
1696    #[tokio::test]
1697    async fn minor_activation_appends_in_order() {
1698        let mut r = ModeRegistry::new();
1699        let one = r.register(MockMode::minor("a-mode")).unwrap();
1700        let two = r.register(MockMode::minor("b-mode")).unwrap();
1701        let three = r.register(MockMode::minor("c-mode")).unwrap();
1702        let mut a = ActiveModes::new();
1703        let g = GuardStoreHandle::new();
1704        let bus = evts();
1705        let mut rx = subscribe_lifecycle(&bus);
1706        for id in [one, two, three] {
1707            r.activate_minor(
1708                &mut a,
1709                &g,
1710                &cfg(),
1711                &bus,
1712                &svcs(),
1713                buf(),
1714                id,
1715                CapabilitySet::empty(),
1716            )
1717            .unwrap();
1718            let _activated = await_lifecycle(&mut rx).await;
1719        }
1720        assert_eq!(a.minors(), &[one, two, three]);
1721    }
1722
1723    #[tokio::test]
1724    async fn minor_re_activation_is_noop() {
1725        let mock = MockMode::minor("a-mode");
1726        let act = mock.activate_calls.clone();
1727        let mut r = ModeRegistry::new();
1728        let id = r.register(mock).unwrap();
1729        let mut a = ActiveModes::new();
1730        let g = GuardStoreHandle::new();
1731        let bus = evts();
1732        let mut rx = subscribe_lifecycle(&bus);
1733        r.activate_minor(
1734            &mut a,
1735            &g,
1736            &cfg(),
1737            &bus,
1738            &svcs(),
1739            buf(),
1740            id,
1741            CapabilitySet::empty(),
1742        )
1743        .unwrap();
1744        let _first = await_lifecycle(&mut rx).await;
1745        // Second call: idempotent no-op; on_activate not called again.
1746        r.activate_minor(
1747            &mut a,
1748            &g,
1749            &cfg(),
1750            &bus,
1751            &svcs(),
1752            buf(),
1753            id,
1754            CapabilitySet::empty(),
1755        )
1756        .unwrap();
1757        // Give the runtime a tick to confirm no second event fires.
1758        tokio::task::yield_now().await;
1759        assert!(
1760            rx.try_recv().is_err(),
1761            "double-activation should not publish a second event"
1762        );
1763        assert_eq!(act.load(Ordering::SeqCst), 1);
1764    }
1765
1766    #[tokio::test]
1767    async fn implies_auto_activates_dependency() {
1768        let mut r = ModeRegistry::new();
1769        let lnum = r.register(MockMode::minor("line-numbers-mode")).unwrap();
1770        let rlnum = r
1771            .register(MockMode::minor("relative-line-numbers-mode").implying(lnum))
1772            .unwrap();
1773        let mut a = ActiveModes::new();
1774        let g = GuardStoreHandle::new();
1775        let bus = evts();
1776        let mut rx = subscribe_lifecycle(&bus);
1777        r.activate_minor(
1778            &mut a,
1779            &g,
1780            &cfg(),
1781            &bus,
1782            &svcs(),
1783            buf(),
1784            rlnum,
1785            CapabilitySet::empty(),
1786        )
1787        .unwrap();
1788        // M-async.3: sequential cascade. Parent's
1789        // `on_activate.await` resolves BEFORE the implied
1790        // child's begins, so events arrive in DFS order:
1791        // parent first, then child.
1792        let evt = await_lifecycle(&mut rx).await;
1793        assert!(
1794            matches!(evt, Event::MinorActivated { ref minor, .. } if minor.as_str() == rlnum.as_str()),
1795            "parent (rlnum) should activate first; got {evt:?}",
1796        );
1797        let evt = await_lifecycle(&mut rx).await;
1798        assert!(
1799            matches!(evt, Event::MinorActivated { ref minor, .. } if minor.as_str() == lnum.as_str()),
1800            "implied child (lnum) should activate second; got {evt:?}",
1801        );
1802        assert!(a.has_minor(rlnum));
1803        assert!(a.has_minor(lnum));
1804    }
1805
1806    // ── RV.1: refresh_action pulls in the shared `gr` minor ──────────
1807    //
1808    // The contract these protect: a mode author writes ONE line
1809    // (`refresh_action`) and gets the chord. If activation needed a
1810    // second declaration (`implies`), forgetting it would kill `gr` as
1811    // silently as the three copied keymap entries RV.1 replaced.
1812
1813    #[tokio::test]
1814    async fn declaring_a_refresh_action_auto_activates_the_shared_minor() {
1815        let mut r = ModeRegistry::new();
1816        crate::refreshable_view_mode::register_refreshable_view_mode(&mut r);
1817        let view = r
1818            .register(MockMode::minor("some-view-mode").refreshing("action:some-view-refresh"))
1819            .unwrap();
1820        let mut a = ActiveModes::new();
1821        let g = GuardStoreHandle::new();
1822        r.activate_minor(
1823            &mut a,
1824            &g,
1825            &cfg(),
1826            &evts(),
1827            &svcs(),
1828            buf(),
1829            view,
1830            CapabilitySet::empty(),
1831        )
1832        .unwrap();
1833        assert!(
1834            a.has_minor(crate::RefreshableViewMode::mode_id()),
1835            "a mode declaring refresh_action must get the shared `gr` minor \
1836             without also listing it in implies()"
1837        );
1838    }
1839
1840    #[tokio::test]
1841    async fn a_major_declaring_a_refresh_action_also_gets_the_shared_minor() {
1842        let mut r = ModeRegistry::new();
1843        crate::refreshable_view_mode::register_refreshable_view_mode(&mut r);
1844        let major = r
1845            .register(MockMode::major("some-view-mode").refreshing("action:some-view-refresh"))
1846            .unwrap();
1847        let mut a = ActiveModes::new();
1848        let g = GuardStoreHandle::new();
1849        r.activate_major(
1850            &mut a,
1851            &g,
1852            &cfg(),
1853            &evts(),
1854            &svcs(),
1855            buf(),
1856            major,
1857            CapabilitySet::empty(),
1858        )
1859        .unwrap();
1860        assert!(
1861            a.has_minor(crate::RefreshableViewMode::mode_id()),
1862            "the cascade must fire for majors too — magit / compilation \
1863             declare their refresh on the major"
1864        );
1865    }
1866
1867    #[tokio::test]
1868    async fn no_refresh_action_leaves_the_shared_minor_off() {
1869        let mut r = ModeRegistry::new();
1870        crate::refreshable_view_mode::register_refreshable_view_mode(&mut r);
1871        let plain = r.register(MockMode::minor("plain-mode")).unwrap();
1872        let mut a = ActiveModes::new();
1873        let g = GuardStoreHandle::new();
1874        r.activate_minor(
1875            &mut a,
1876            &g,
1877            &cfg(),
1878            &evts(),
1879            &svcs(),
1880            buf(),
1881            plain,
1882            CapabilitySet::empty(),
1883        )
1884        .unwrap();
1885        assert!(
1886            !a.has_minor(crate::RefreshableViewMode::mode_id()),
1887            "`gr` must not attach to ordinary buffers — it is LSP references there"
1888        );
1889    }
1890
1891    /// An unregistered shared minor must not fail activation: a trimmed
1892    /// build or a focused test harness may not register it, and the
1893    /// right outcome is "no `gr`", not "the view refuses to open".
1894    #[tokio::test]
1895    async fn missing_shared_minor_does_not_fail_activation() {
1896        let mut r = ModeRegistry::new();
1897        let view = r
1898            .register(MockMode::minor("some-view-mode").refreshing("action:some-view-refresh"))
1899            .unwrap();
1900        let mut a = ActiveModes::new();
1901        let g = GuardStoreHandle::new();
1902        let res = r.activate_minor(
1903            &mut a,
1904            &g,
1905            &cfg(),
1906            &evts(),
1907            &svcs(),
1908            buf(),
1909            view,
1910            CapabilitySet::empty(),
1911        );
1912        assert!(res.is_ok(), "missing shared minor must degrade, not fail");
1913        assert!(a.has_minor(view));
1914    }
1915
1916    #[tokio::test]
1917    async fn implies_unregistered_dependency_fails() {
1918        let mut r = ModeRegistry::new();
1919        let phantom = ModeId::new("ghost-mode");
1920        let id = r
1921            .register(MockMode::minor("thing-mode").implying(phantom))
1922            .unwrap();
1923        let mut a = ActiveModes::new();
1924        let g = GuardStoreHandle::new();
1925        let err = r
1926            .activate_minor(
1927                &mut a,
1928                &g,
1929                &cfg(),
1930                &evts(),
1931                &svcs(),
1932                buf(),
1933                id,
1934                CapabilitySet::empty(),
1935            )
1936            .unwrap_err();
1937        assert!(matches!(
1938            err,
1939            ModeActivationError::UnregisteredDependency { .. }
1940        ));
1941    }
1942
1943    #[tokio::test]
1944    async fn conflict_blocks_activation() {
1945        let mut r = ModeRegistry::new();
1946        let one_id = ModeId::new("vim-paste-mode");
1947        let two_id = ModeId::new("auto-pair-mode");
1948        let one = r
1949            .register(MockMode::minor("vim-paste-mode").conflicting_with(two_id))
1950            .unwrap();
1951        let two = r.register(MockMode::minor("auto-pair-mode")).unwrap();
1952        assert_eq!(one, one_id);
1953        assert_eq!(two, two_id);
1954        let mut a = ActiveModes::new();
1955        let g = GuardStoreHandle::new();
1956        let bus = evts();
1957        let mut rx = subscribe_lifecycle(&bus);
1958        r.activate_minor(
1959            &mut a,
1960            &g,
1961            &cfg(),
1962            &bus,
1963            &svcs(),
1964            buf(),
1965            two,
1966            CapabilitySet::empty(),
1967        )
1968        .unwrap();
1969        let _two_activated = await_lifecycle(&mut rx).await;
1970        let err = r
1971            .activate_minor(
1972                &mut a,
1973                &g,
1974                &cfg(),
1975                &bus,
1976                &svcs(),
1977                buf(),
1978                one,
1979                CapabilitySet::empty(),
1980            )
1981            .unwrap_err();
1982        match err {
1983            ModeActivationError::Conflict { mode, active } => {
1984                assert_eq!(mode, one);
1985                assert_eq!(active, two);
1986            }
1987            _ => panic!("expected Conflict"),
1988        }
1989    }
1990
1991    #[tokio::test]
1992    async fn deactivate_minor_drops_guard_and_publishes_event() {
1993        let mock = MockMode::minor("a-mode");
1994        let deact = mock.deactivate_calls.clone();
1995        let mut r = ModeRegistry::new();
1996        let id = r.register(mock).unwrap();
1997        let mut a = ActiveModes::new();
1998        let g = GuardStoreHandle::new();
1999        let bus = evts();
2000        let mut rx = subscribe_lifecycle(&bus);
2001        r.activate_minor(
2002            &mut a,
2003            &g,
2004            &cfg(),
2005            &bus,
2006            &svcs(),
2007            buf(),
2008            id,
2009            CapabilitySet::empty(),
2010        )
2011        .unwrap();
2012        let _activated = await_lifecycle(&mut rx).await;
2013        r.deactivate_minor(&mut a, &g, &bus, buf(), id).unwrap();
2014        let evt = await_lifecycle(&mut rx).await;
2015        assert!(matches!(evt, Event::MinorDeactivated { minor, .. } if minor == id.as_str()));
2016        assert_eq!(deact.load(Ordering::SeqCst), 1, "Guard::Drop ran");
2017        assert!(!a.has_minor(id));
2018        assert!(!g.contains(buf(), id));
2019    }
2020
2021    #[tokio::test]
2022    async fn deactivate_inactive_minor_is_noop() {
2023        let mut r = ModeRegistry::new();
2024        let id = r.register(MockMode::minor("a-mode")).unwrap();
2025        let mut a = ActiveModes::new();
2026        let g = GuardStoreHandle::new();
2027        let bus = evts();
2028        let mut rx = subscribe_lifecycle(&bus);
2029        r.deactivate_minor(&mut a, &g, &bus, buf(), id).unwrap();
2030        tokio::task::yield_now().await;
2031        assert!(
2032            rx.try_recv().is_err(),
2033            "no-op deactivate should not publish"
2034        );
2035    }
2036
2037    #[tokio::test]
2038    async fn deactivate_major_drops_guard_and_clears() {
2039        let mock = MockMode::major("rust-mode");
2040        let deact = mock.deactivate_calls.clone();
2041        let mut r = ModeRegistry::new();
2042        let id = r.register(mock).unwrap();
2043        let mut a = ActiveModes::new();
2044        let g = GuardStoreHandle::new();
2045        let bus = evts();
2046        let mut rx = subscribe_lifecycle(&bus);
2047        r.activate_major(
2048            &mut a,
2049            &g,
2050            &cfg(),
2051            &bus,
2052            &svcs(),
2053            buf(),
2054            id,
2055            CapabilitySet::empty(),
2056        )
2057        .unwrap();
2058        let _entered = await_lifecycle(&mut rx).await;
2059        r.deactivate_major(&mut a, &g, &bus, buf()).unwrap();
2060        let evt = await_lifecycle(&mut rx).await;
2061        assert!(matches!(evt, Event::MajorExiting { major, .. } if major == id.as_str()));
2062        assert_eq!(deact.load(Ordering::SeqCst), 1);
2063        assert_eq!(a.major(), None);
2064        assert!(!g.contains(buf(), id));
2065    }
2066
2067    #[tokio::test]
2068    async fn deactivate_major_when_none_active_is_noop() {
2069        let r = ModeRegistry::new();
2070        let mut a = ActiveModes::new();
2071        let g = GuardStoreHandle::new();
2072        let bus = evts();
2073        let mut rx = subscribe_lifecycle(&bus);
2074        r.deactivate_major(&mut a, &g, &bus, buf()).unwrap();
2075        tokio::task::yield_now().await;
2076        assert!(rx.try_recv().is_err());
2077    }
2078
2079    /// Lifecycle that returns `Err`: dispatcher publishes
2080    /// `ModeActivationFailed` instead of `MinorActivated`.
2081    /// `active_modes` was already mutated by the sync prefix --
2082    /// M-async.3 rolls it back via subscriber.
2083    #[tokio::test]
2084    async fn lifecycle_err_publishes_activation_failed() {
2085        struct FailingMode {
2086            id: ModeId,
2087        }
2088        impl Mode for FailingMode {
2089            type Guard = ();
2090            fn id(&self) -> ModeId {
2091                self.id
2092            }
2093            fn kind(&self) -> ModeKind {
2094                ModeKind::Minor
2095            }
2096            fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
2097                let id = self.id;
2098                Box::pin(async move { Err(ModeActivationError::NotRegistered(id)) })
2099            }
2100        }
2101        let mut r = ModeRegistry::new();
2102        let id = r
2103            .register(FailingMode {
2104                id: ModeId::new("fail-mode"),
2105            })
2106            .unwrap();
2107        let mut a = ActiveModes::new();
2108        let g = GuardStoreHandle::new();
2109        let bus = evts();
2110        let mut rx = subscribe_mode_events(&bus);
2111        r.activate_minor(
2112            &mut a,
2113            &g,
2114            &cfg(),
2115            &bus,
2116            &svcs(),
2117            buf(),
2118            id,
2119            CapabilitySet::empty(),
2120        )
2121        .unwrap();
2122        let evt = await_event(&mut rx).await;
2123        match evt {
2124            ModeEvent::ModeActivationFailed { mode, reason, .. } => {
2125                assert_eq!(mode, id);
2126                assert!(reason.contains("not registered"));
2127            }
2128            other => panic!("expected ModeActivationFailed, got {other:?}"),
2129        }
2130        assert!(
2131            !g.contains(buf(), id),
2132            "no Guard stashed on lifecycle error"
2133        );
2134    }
2135
2136    /// M-async.3 cascade abort: parent's `on_activate` returns
2137    /// `Err` → publishes `ModeActivationFailed` for the parent
2138    /// *and* a synthetic "cascade aborted by parent"
2139    /// `ModeActivationFailed` for each unrun implied child.
2140    /// Children never spawn; their Guards never stash; the
2141    /// App's rollback subscriber sees one event per unrun mode
2142    /// and clears `active_modes` for the whole subtree.
2143    #[tokio::test]
2144    async fn cascade_abort_publishes_synthetic_failures_for_unrun_steps() {
2145        struct FailingParent {
2146            id: ModeId,
2147            implies: Vec<ModeId>,
2148        }
2149        impl Mode for FailingParent {
2150            type Guard = ();
2151            fn id(&self) -> ModeId {
2152                self.id
2153            }
2154            fn kind(&self) -> ModeKind {
2155                ModeKind::Minor
2156            }
2157            fn implies(&self) -> &[ModeId] {
2158                &self.implies
2159            }
2160            fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, ()> {
2161                let id = self.id;
2162                Box::pin(async move { Err(ModeActivationError::NotRegistered(id)) })
2163            }
2164        }
2165        let mut r = ModeRegistry::new();
2166        let child_id = r.register(MockMode::minor("child-mode")).unwrap();
2167        let parent_id = ModeId::new("parent-mode");
2168        r.register(FailingParent {
2169            id: parent_id,
2170            implies: vec![child_id],
2171        })
2172        .unwrap();
2173        let mut a = ActiveModes::new();
2174        let g = GuardStoreHandle::new();
2175        let bus = evts();
2176        let mut rx = subscribe_mode_events(&bus);
2177        r.activate_minor(
2178            &mut a,
2179            &g,
2180            &cfg(),
2181            &bus,
2182            &svcs(),
2183            buf(),
2184            parent_id,
2185            CapabilitySet::empty(),
2186        )
2187        .unwrap();
2188        // Sync prefix mutated active_modes for parent + child.
2189        assert!(a.has_minor(parent_id));
2190        assert!(a.has_minor(child_id));
2191        // First event: parent's real failure.
2192        let evt = await_event(&mut rx).await;
2193        match evt {
2194            ModeEvent::ModeActivationFailed { mode, reason, .. } => {
2195                assert_eq!(mode, parent_id);
2196                assert!(
2197                    !reason.starts_with("cascade aborted"),
2198                    "parent's reason should be the real error, not the synthetic prefix; got {reason:?}",
2199                );
2200            }
2201            other => panic!("expected parent's ModeActivationFailed first, got {other:?}"),
2202        }
2203        // Second event: child's synthetic "cascade aborted".
2204        let evt = await_event(&mut rx).await;
2205        match evt {
2206            ModeEvent::ModeActivationFailed { mode, reason, .. } => {
2207                assert_eq!(mode, child_id);
2208                assert!(
2209                    reason.contains("cascade aborted"),
2210                    "child's reason should announce cascade abort; got {reason:?}",
2211                );
2212                assert!(reason.contains(parent_id.as_str()));
2213            }
2214            other => panic!("expected child's synthetic ModeActivationFailed, got {other:?}"),
2215        }
2216        // No Guards stashed -- neither parent nor child ran to
2217        // completion.
2218        assert!(!g.contains(buf(), parent_id));
2219        assert!(!g.contains(buf(), child_id));
2220    }
2221
2222    /// M-async.4: a mode whose `on_activate` truly `.await`s
2223    /// (yields Pending on first poll) trips the
2224    /// try-sync-then-spawn driver into the spawn path. If a
2225    /// deactivate arrives before the spawn completes, the
2226    /// epoch bump in `remove` invalidates the in-flight spawn's
2227    /// captured epoch; its later `try_insert` fails the match
2228    /// and the Guard drops on the spawn side instead of
2229    /// stashing into a logically-inactive store slot.
2230    ///
2231    /// This test pins that contract: rapid `activate →
2232    /// deactivate` against an `.await`ing mode produces no
2233    /// leaked Guard, and the stale Guard's `Drop` still fires
2234    /// (out-of-band; the dispatcher relies on Drop for
2235    /// cleanup correctness).
2236    #[tokio::test]
2237    async fn rapid_deactivate_during_pending_activate_drops_guard_on_spawn_side() {
2238        use tokio::sync::oneshot;
2239
2240        /// Guard whose Drop bumps an atomic counter. The test
2241        /// asserts the counter increments even when the spawn
2242        /// detects a stale epoch.
2243        struct DropTrackingGuard {
2244            counter: StdArc<AtomicU32>,
2245        }
2246        impl Drop for DropTrackingGuard {
2247            fn drop(&mut self) {
2248                self.counter.fetch_add(1, Ordering::SeqCst);
2249            }
2250        }
2251
2252        /// Mode whose `on_activate` `.await`s a oneshot before
2253        /// returning the Guard. Lets the test interleave: spawn
2254        /// task is parked, deactivate runs, then we release
2255        /// the oneshot and watch the spawn task try to insert.
2256        struct GatedMode {
2257            id: ModeId,
2258            gate_rx: std::sync::Mutex<Option<oneshot::Receiver<()>>>,
2259            drop_counter: StdArc<AtomicU32>,
2260        }
2261        impl Mode for GatedMode {
2262            type Guard = DropTrackingGuard;
2263            fn id(&self) -> ModeId {
2264                self.id
2265            }
2266            fn kind(&self) -> ModeKind {
2267                ModeKind::Minor
2268            }
2269            fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
2270                let rx = self
2271                    .gate_rx
2272                    .lock()
2273                    .unwrap()
2274                    .take()
2275                    .expect("oneshot consumed twice");
2276                let counter = self.drop_counter.clone();
2277                Box::pin(async move {
2278                    // Yield Pending until the test releases the gate.
2279                    let _ = rx.await;
2280                    Ok(DropTrackingGuard { counter })
2281                })
2282            }
2283        }
2284
2285        let drop_counter = StdArc::new(AtomicU32::new(0));
2286        let (gate_tx, gate_rx) = oneshot::channel::<()>();
2287        let mode = GatedMode {
2288            id: ModeId::new("gated-mode"),
2289            gate_rx: std::sync::Mutex::new(Some(gate_rx)),
2290            drop_counter: drop_counter.clone(),
2291        };
2292        let mut r = ModeRegistry::new();
2293        let id = r.register(mode).unwrap();
2294        let mut a = ActiveModes::new();
2295        let g = GuardStoreHandle::new();
2296        let bus = evts();
2297        let mut rx = subscribe_lifecycle(&bus);
2298
2299        // Activate -- sync prefix mutates active_modes + bumps
2300        // epoch; first poll yields Pending (oneshot not
2301        // released) so the driver spawns the rest.
2302        r.activate_minor(
2303            &mut a,
2304            &g,
2305            &cfg(),
2306            &bus,
2307            &svcs(),
2308            buf(),
2309            id,
2310            CapabilitySet::empty(),
2311        )
2312        .unwrap();
2313        assert!(a.has_minor(id));
2314        assert!(
2315            !g.contains(buf(), id),
2316            "Guard not stashed yet (spawn pending)"
2317        );
2318
2319        // Deactivate immediately. Synchronous: bumps epoch
2320        // (invalidating in-flight spawn), removes from
2321        // active_modes, publishes MinorDeactivated.
2322        // guards.remove returns None (Guard wasn't in store
2323        // yet) so no Drop fires here.
2324        r.deactivate_minor(&mut a, &g, &bus, buf(), id).unwrap();
2325        assert!(!a.has_minor(id));
2326        // MinorDeactivated published from the sync deactivate.
2327        let evt = await_lifecycle(&mut rx).await;
2328        assert!(
2329            matches!(evt, Event::MinorDeactivated { minor, .. } if minor == id.as_str()),
2330            "deactivate should publish MinorDeactivated synchronously",
2331        );
2332        assert_eq!(
2333            drop_counter.load(Ordering::SeqCst),
2334            0,
2335            "Guard hasn't been constructed yet -- spawn is parked on oneshot",
2336        );
2337
2338        // Release the spawn's `.await`. It now resolves +
2339        // tries to try_insert. Epoch mismatch (we bumped via
2340        // deactivate's remove) → returns Err(guard) → Guard
2341        // dropped on the spawn side → DropTrackingGuard::drop
2342        // fires.
2343        gate_tx.send(()).unwrap();
2344        // Wait for the spawn task to observe the wake + run
2345        // its drop. Tokio multi-thread runtime is shared
2346        // across tests so contention can stretch wall-time;
2347        // poll-and-sleep with a generous budget rather than
2348        // a tight yield loop.
2349        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
2350        while drop_counter.load(Ordering::SeqCst) == 0 {
2351            if std::time::Instant::now() >= deadline {
2352                break;
2353            }
2354            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
2355        }
2356        assert_eq!(
2357            drop_counter.load(Ordering::SeqCst),
2358            1,
2359            "stale Guard's Drop should have fired on the spawn side",
2360        );
2361        assert!(
2362            !g.contains(buf(), id),
2363            "Guard must NOT be stashed in the store -- the deactivate already happened",
2364        );
2365        // No spurious MinorActivated event for the activation
2366        // that ended up stale.
2367        assert!(
2368            rx.try_recv().is_err(),
2369            "stale activation should not publish MinorActivated",
2370        );
2371    }
2372}