Skip to main content

lattice_config/
registry.rs

1//! [`ConfigRegistry`] — the central typed-options store
2//! (DESIGN.md §5.12).
3//!
4//! Holds every registered option behind an [`crate::ErasedOption`]
5//! trait object. Two access patterns:
6//!
7//! 1. **Typed handle** ([`crate::OptionHandle<T>`]) — returned by
8//!    [`Self::register`]. Zero-overhead reads through
9//!    [`Self::get`] / [`Self::with`]. Used by the App and
10//!    renderers for hot-path option reads.
11//!
12//! 2. **By-name** ([`Self::lookup`] / [`Self::parse_and_set_command`])
13//!    — driven by the cmdline `:set foo=bar` / `:set foo?`,
14//!    the customize buffer view, and plugin introspection. The
15//!    name is the public API surface; aliases resolve to the same
16//!    spec.
17//!
18//! Concurrent reads through handles are wait-free (each
19//! [`crate::Option<T>`]'s value cell is an `ArcSwap<T>`). The
20//! registry's `by_id` / `by_name` maps live behind a `Mutex` because
21//! adding entries shifts the `by_id` vec; in practice registration
22//! happens once at App boot and once per plugin activation, never
23//! on hot paths. Read-by-handle takes the registry mutex briefly to
24//! pull the `Arc<dyn ErasedOption>` then drops it before reading
25//! the cell, so the lock window is microscopic.
26
27use std::any::TypeId;
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30
31use lattice_protocol::Event;
32
33use crate::erased::ErasedOption;
34use crate::option::{Option, OptionHandle};
35use crate::option_decl::{OPTION_DECLS, OptionDecl};
36use crate::option_type::OptionType;
37use crate::parse::{ParsedSet, parse_set};
38
39/// Sink the registry calls after every successful set so consumers
40/// can react to typed-option changes through the §5.10 event bus.
41/// Stored as a `Box<dyn Fn>` so `lattice-config` doesn't depend on
42/// `lattice-runtime`'s `EventBus` directly -- the App wires
43/// `event_bus.publish(event)` as the closure body at boot.
44///
45/// The publisher is invoked synchronously from the same thread
46/// that drove the set; downstream subscribers should not assume
47/// any particular thread context.
48pub type EventPublisher = Arc<dyn Fn(Event) + Send + Sync>;
49
50/// Process-shared registry.
51#[derive(Default)]
52pub struct ConfigRegistry {
53    inner: Mutex<Inner>,
54}
55
56/// OC.11c: one failed assignment to a named option.
57///
58/// A failed assignment is a NO-OP — vim's rule, which lattice keeps — so the
59/// option keeps whatever it had, and for one never successfully set that is
60/// its registered default. Reading the value therefore cannot tell "the user
61/// configured this and it did not parse" from "the user never configured
62/// this". This record is what can.
63///
64/// **It is not a state the option carries.** The option has no such state; an
65/// assignment errored, which is an event, and this is the record of that
66/// event. It is dropped the moment a later assignment to the same option
67/// succeeds, because at that point it describes something untrue.
68#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct ConfigDiagnostic {
70    /// The message the loader or this registry produced, verbatim. For a
71    /// composite it carries the schema PATH (`[2].target.file: expected
72    /// string, got integer`), which is the whole reason this is worth
73    /// surfacing rather than a bare "it failed".
74    pub message: String,
75    /// The config file it came from, or `None` for a runtime `:set`. That is
76    /// the difference between "go fix your config" and "what you just typed
77    /// did not take".
78    pub source: std::option::Option<std::path::PathBuf>,
79}
80
81#[derive(Default)]
82struct Inner {
83    /// OC.11c: failed assignments, keyed by CANONICAL option name.
84    ///
85    /// Here rather than on `Editor` because its lifetime is the registry's:
86    /// it is written by the two paths that assign options and invalidated by
87    /// the same events that change them. Holding it a layer up meant the
88    /// clear-on-success had to be remembered by every caller, which is the
89    /// shape that gets forgotten.
90    diagnostics: HashMap<String, ConfigDiagnostic>,
91    /// Indexed by [`OptionHandle::idx`]. A slot is `Some` while the
92    /// option is live and `None` once unregistered (PH7.12b): the
93    /// index IS the handle, so a slot can never shift or be reused
94    /// under a live handle without invalidating it — hence a tombstone
95    /// (set the slot to `None`) rather than `Vec::remove`. Freed
96    /// indices are recycled through [`Inner::free_list`] so a plugin
97    /// reload re-registering the same options reuses the slots instead
98    /// of growing this vec unbounded across reloads (audit F6).
99    by_id: Vec<std::option::Option<Arc<dyn ErasedOption>>>,
100    /// Tombstoned `by_id` indices, available for reuse by the next
101    /// registration (PH7.12b). Keeps `by_id` bounded across plugin
102    /// reload cycles; empty in the common register-only lifetime.
103    free_list: Vec<usize>,
104    /// Name + alias → index. Multiple entries (canonical name +
105    /// each alias) all point at the same `by_id` index.
106    by_name: HashMap<String, usize>,
107    /// `TypeId` of an [`crate::OptionDecl`] type → index. Populated
108    /// by [`ConfigRegistry::register_with_typeid`] (called from
109    /// the proc-macro's self-registration thunk during
110    /// [`ConfigRegistry::init_from_linkme`]). The hot-path
111    /// type-keyed read [`ConfigRegistry::get_typed`] looks up
112    /// here; absent entries (option not registered) return `None`.
113    by_typeid: HashMap<TypeId, usize>,
114    /// Optional sink for [`Event::OptionChanged`] publishing
115    /// (DESIGN.md §5.10 / §5.12). `None` means "no event publish",
116    /// useful in tests and for embedded uses that don't run an
117    /// event bus. The App wires this at boot via
118    /// [`ConfigRegistry::set_event_publisher`] -- the closure
119    /// body calls `event_bus.publish(event)`.
120    event_publisher: std::option::Option<EventPublisher>,
121}
122
123/// What the registry's fallible operations can fail with. Kept
124/// separate from raw `String` errors so callers can echo the
125/// canonical message without re-stringifying.
126///
127/// Vim-style `E` codes match the existing TUI cmdline echo
128/// wording; renderer-agnostic by design (every renderer surfaces
129/// these the same way).
130#[derive(Debug, thiserror::Error)]
131pub enum ConfigError {
132    /// No option (or alias) is registered under this name. Also
133    /// returned by [`ConfigRegistry::parse_for_buffer_local`] for an
134    /// option registered without a `TypeId` (it cannot be layered).
135    #[error("E518: Unknown option: {0}")]
136    UnknownOption(String),
137    /// [`ConfigRegistry::try_register`] found this name or alias
138    /// already taken. Nothing was registered.
139    #[error("E448: option `{0}` already registered")]
140    DuplicateName(String),
141    /// `:set noFOO` against a non-bool option. Wording matches the
142    /// pre-typed-options TUI echo.
143    #[error("E474: not a boolean option: {0}")]
144    NotBoolean(String),
145    /// `:set tabstop=999` (out-of-range) etc. — produced by the
146    /// option's validate closure. Wording is the closure's verbatim.
147    #[error("{0}")]
148    Validation(String),
149    /// `:set foldmethod=xyz` — produced by the option type's
150    /// `parse` impl. Wording is the impl's verbatim.
151    #[error("{0}")]
152    Parse(String),
153    /// `name?` passed to `parse_for_buffer_local`. Query forms are
154    /// not writes; callers should use `:set name?` or `:setlocal name?`
155    /// to echo instead of calling into the write path.
156    #[error("E474: query form not allowed in :setlocal; use :set {0}? to echo")]
157    QueryNotAllowed(String),
158    /// A typed access named a value type the registered option does
159    /// not have. Currently constructed nowhere in the workspace — the
160    /// typed paths report a mismatch as `None` ([`ConfigRegistry::try_get`])
161    /// or a `String` error ([`ConfigRegistry::set`]) instead.
162    #[error("E474: type mismatch: handle expected `{expected}`, registry has `{actual}`")]
163    TypeMismatch {
164        /// The accessor's [`OptionType::type_label`].
165        expected: &'static str,
166        /// The registered option's type label.
167        actual: &'static str,
168    },
169}
170
171#[cold]
172#[track_caller]
173#[allow(clippy::panic)]
174fn panic_on_duplicate(e: &ConfigError) -> ! {
175    // Only the infallible `register` shim calls this; the message
176    // includes the offending name via the ConfigError variant. The
177    // codebase's no-panic policy makes a controlled exception for
178    // genuine programming errors at registration time -- duplicate
179    // names are a build-the-app bug, not a user-input failure.
180    // Callers that need recovery use `try_register`.
181    panic!("{e}")
182}
183
184impl ConfigRegistry {
185    /// An empty registry with no event publisher. Options arrive via
186    /// [`Self::init_from_linkme`] (every `options!` declaration linked
187    /// into the binary) and [`Self::register`] (runtime / plugin
188    /// options).
189    ///
190    /// # Examples
191    ///
192    /// Declared options, read and written by type:
193    ///
194    /// ```
195    /// use lattice_config::{ConfigRegistry, Tabstop};
196    ///
197    /// let reg = ConfigRegistry::new();
198    /// assert!(reg.get_typed::<Tabstop>().is_none()); // nothing registered yet
199    /// reg.init_from_linkme();
200    ///
201    /// assert_eq!(*reg.get_typed::<Tabstop>().unwrap(), 4); // the declared default
202    /// reg.set_typed::<Tabstop>(2).unwrap();
203    /// assert_eq!(*reg.get_typed::<Tabstop>().unwrap(), 2);
204    /// // The option's validator (1..=32) rejects the write; the old value stays.
205    /// assert!(reg.set_typed::<Tabstop>(0).is_err());
206    /// assert_eq!(*reg.get_typed::<Tabstop>().unwrap(), 2);
207    /// ```
208    ///
209    /// A runtime-registered option, read through its handle:
210    ///
211    /// ```
212    /// use lattice_config::ConfigRegistry;
213    /// use lattice_config::option::Option;
214    ///
215    /// let reg = ConfigRegistry::new();
216    /// let depth = reg.register(
217    ///     Option::<i64>::builder("myplugin.depth", 3, "Search depth.")
218    ///         .aliases(&["mpd"])
219    ///         .validate(|d| if *d > 0 { Ok(()) } else { Err(format!("depth must be > 0, got {d}")) })
220    ///         .build(),
221    /// );
222    /// assert_eq!(*reg.get(depth), 3);
223    /// assert_eq!(reg.parse_and_set_command("mpd=5").unwrap(), "myplugin.depth=5");
224    /// assert_eq!(reg.with(depth, |d| *d), 5);
225    /// assert!(reg.set(depth, -1).is_err());
226    /// ```
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Install the [`Event::OptionChanged`] sink. Idempotent
232    /// replacement -- calling twice swaps the closure. Designed to
233    /// be called once at boot from the consumer that owns the
234    /// `EventBus` (the App today; future plugin-host paths route
235    /// through the same closure).
236    pub fn set_event_publisher(&self, publisher: EventPublisher) {
237        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
238        inner.event_publisher = Some(publisher);
239    }
240
241    /// Publish helper: capture old + new and dispatch to the
242    /// registered publisher (if any). Old value is captured
243    /// *before* the set; new is captured *after*. We re-look-up
244    /// the spec under the lock to read both sides atomically wrt
245    /// the publisher fan-out -- but the publisher itself runs
246    /// outside the lock so subscribers can re-enter the registry
247    /// safely (e.g. read another option).
248    fn publish_change(&self, name: &str, old: std::option::Option<String>) {
249        let (publisher, new) = {
250            let inner = self.inner.lock().expect("ConfigRegistry poisoned");
251            let publisher = inner.event_publisher.clone();
252            let new = inner
253                .by_name
254                .get(name)
255                .and_then(|i| inner.by_id[*i].as_ref())
256                .map(|o| o.get_formatted());
257            (publisher, new)
258        };
259        if let (Some(publisher), Some(new)) = (publisher, new) {
260            publisher(Event::OptionChanged {
261                name: name.to_string(),
262                old,
263                new,
264            });
265        }
266    }
267
268    /// Register a typed option. Returns an [`OptionHandle<T>`] for
269    /// hot-path access. Returns `Err` if `option.name` or any alias
270    /// is already registered (duplicate-name detection is a
271    /// programming-error guard; recoverable via the `Result` so the
272    /// caller can decide whether a duplicate is fatal).
273    #[track_caller]
274    pub fn register<T: OptionType>(&self, option: Option<T>) -> OptionHandle<T> {
275        self.try_register(option)
276            .unwrap_or_else(|e| panic_on_duplicate(&e))
277    }
278
279    /// Fallible registration. Most callers want [`Self::register`];
280    /// this exists for plugins / dynamic code that can fail
281    /// gracefully on a name collision.
282    pub fn try_register<T: OptionType>(
283        &self,
284        option: Option<T>,
285    ) -> Result<OptionHandle<T>, ConfigError> {
286        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
287        // PL8.F: own the name up front — `option.name()` now borrows `option`
288        // (a `Cow` field), and `option` is moved into the `Arc` below, so the
289        // key insert at the tail would otherwise be a borrow-after-move.
290        let name = option.name().to_owned();
291        let aliases = option.aliases;
292        if inner.by_name.contains_key(name.as_str()) {
293            return Err(ConfigError::DuplicateName(name));
294        }
295        for a in aliases {
296            if inner.by_name.contains_key(*a) {
297                return Err(ConfigError::DuplicateName((*a).to_string()));
298            }
299        }
300        let arc: Arc<dyn ErasedOption> = Arc::new(option);
301        // Reuse a tombstoned slot if one is free (a prior unregister) so
302        // `by_id` stays bounded across plugin reloads (PH7.12b); otherwise
303        // append. Either way the index is stable for the returned handle.
304        let idx = match inner.free_list.pop() {
305            Some(reused) => {
306                inner.by_id[reused] = Some(arc);
307                reused
308            }
309            None => {
310                inner.by_id.push(Some(arc));
311                inner.by_id.len() - 1
312            }
313        };
314        inner.by_name.insert(name, idx);
315        for a in aliases {
316            inner.by_name.insert((*a).to_string(), idx);
317        }
318        Ok(OptionHandle::<T>::new(idx))
319    }
320
321    /// Variant of [`Self::register`] that additionally records a
322    /// `TypeId` ↔ option-index mapping for type-keyed reads via
323    /// [`Self::get_typed`]. Called from the
324    /// [`crate::options!`] macro's `register_fn` thunk during
325    /// [`Self::init_from_linkme`]; not generally called by hand.
326    ///
327    /// `type_id` is `TypeId::of::<D>()` where `D` is the
328    /// [`crate::OptionDecl`] type. Two declarations with the same
329    /// `TypeId` cannot exist (Rust's type system enforces it
330    /// cross-crate), so duplicate-typeid is a programming error
331    /// and panics; the macro never produces it.
332    #[track_caller]
333    pub fn register_with_typeid<T: OptionType>(
334        &self,
335        option: Option<T>,
336        type_id: TypeId,
337    ) -> OptionHandle<T> {
338        let handle = self.register(option);
339        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
340        if inner.by_typeid.insert(type_id, handle.idx).is_some() {
341            // Two registrations for the same type id is a build
342            // bug -- shouldn't be reachable from the macro. We
343            // use `unreachable!` (which clippy permits) rather
344            // than `panic!` because the path is structurally
345            // impossible to reach in correct callers.
346            #[allow(clippy::panic)]
347            {
348                panic!("config: duplicate TypeId in register_with_typeid");
349            }
350        }
351        handle
352    }
353
354    /// Type-keyed read: returns the resolved value for the
355    /// [`OptionDecl`] type `D`. `D::Value` is the value type.
356    /// Returns `None` if the option has not been registered (no
357    /// `register_with_typeid` call seen for this `TypeId`); this
358    /// is legitimate transient state during boot before
359    /// [`Self::init_from_linkme`] runs.
360    pub fn get_typed<D: OptionDecl>(&self) -> std::option::Option<Arc<D::Value>>
361    where
362        D::Value: Clone + Send + Sync + 'static,
363    {
364        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
365        let idx = *inner.by_typeid.get(&TypeId::of::<D>())?;
366        let arc = Arc::clone(inner.by_id[idx].as_ref()?);
367        drop(inner);
368        let opt = arc.as_any().downcast_ref::<Option<D::Value>>()?;
369        Some(opt.get())
370    }
371
372    /// Type-keyed handle lookup: recover the legacy
373    /// [`OptionHandle<D::Value>`] from an [`OptionDecl`] type.
374    /// Used by the M.2.0b backwards-compatibility shim in
375    /// `core_options::register_core_options` to populate the
376    /// `CoreOptions` struct with handles after
377    /// [`Self::init_from_linkme`] has run.
378    ///
379    /// Returns `None` if `D` was not registered. M.2.0c retires
380    /// the `OptionHandle<T>` API and this method along with it.
381    pub fn handle_for_decl<D: OptionDecl>(&self) -> std::option::Option<OptionHandle<D::Value>> {
382        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
383        let idx = *inner.by_typeid.get(&TypeId::of::<D>())?;
384        Some(OptionHandle::<D::Value>::new(idx))
385    }
386
387    /// Type-keyed write: set the option declared by `D` to `value`.
388    /// Runs the option's validator before committing, publishes
389    /// [`Event::OptionChanged`] on success, returns the validator
390    /// error (or a missing-registration error) on failure.
391    ///
392    /// Hot-path-equivalent to the legacy `set(handle, value)`
393    /// shape; one TypeId lookup per write. Writes are rare (user
394    /// `:set foo=bar` cmdline, programmatic toggles), so the
395    /// extra hash bears no measurable cost.
396    pub fn set_typed<D: OptionDecl>(&self, value: D::Value) -> Result<(), String>
397    where
398        D::Value: Clone + Send + Sync + 'static,
399    {
400        let handle = self
401            .handle_for_decl::<D>()
402            .ok_or_else(|| format!("config: option `{}` not registered", D::NAME))?;
403        self.set(handle, value)
404    }
405
406    /// Bootstrap a [`crate::ResolvedOptions`] cache with every
407    /// registered option's *current* value (layer 5 + 6 of the
408    /// resolution stack -- `mode-architecture.md` §6.1). Walks
409    /// the typeid map and writes each option's
410    /// [`crate::ErasedOption::current_value_erased`] into the
411    /// cache, keyed by `TypeId`.
412    ///
413    /// Used by [`crate::Resolver::resolve_into`] callers that
414    /// want a fully-populated resolved cache without manually
415    /// chaining a "default" layer. After this returns, the
416    /// caller layers any mode / buffer-local / modal overrides
417    /// on top via the resolver.
418    pub fn bootstrap_resolved_with_current_values(&self, out: &mut crate::ResolvedOptions) {
419        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
420        for (type_id, &idx) in inner.by_typeid.iter() {
421            let Some(arc) = inner.by_id[idx].as_ref() else {
422                continue;
423            };
424            let arc = std::sync::Arc::clone(arc);
425            let value_erased = arc.current_value_erased();
426            out.insert_erased_with_origin(
427                *type_id,
428                value_erased,
429                crate::OptionOrigin::GlobalConfig,
430            );
431        }
432    }
433
434    /// Look up the `TypeId` registered for an option's canonical name.
435    /// Returns `None` if the option was not registered via
436    /// `register_with_typeid` (e.g. a bare `try_register` call, or the
437    /// option simply doesn't exist).
438    pub fn type_id_for_name(&self, name: &str) -> std::option::Option<std::any::TypeId> {
439        self.typeid_for_name(name).ok()
440    }
441
442    /// Boot loop: walk the [`OPTION_DECLS`] linkme slice and
443    /// register every option declared anywhere in the workspace.
444    /// Idempotent: calling more than once is a no-op (the second
445    /// call observes that every option is already registered and
446    /// returns without doing anything). Boot panics on a true
447    /// programming error (e.g. two distinct `OptionDecl` types
448    /// happen to declare the same display name across crates).
449    ///
450    /// After this returns, every `options! { ... }` declaration
451    /// is reachable via [`Self::get_typed`] / [`Self::lookup`] /
452    /// the cmdline `:set` / TOML loader. Boot is the moment when
453    /// "the option exists" goes from "compile-time fact in the
454    /// declaring crate" to "runtime fact in the registry."
455    pub fn init_from_linkme(&self) {
456        let already_registered = {
457            let inner = self.inner.lock().expect("ConfigRegistry poisoned");
458            !inner.by_typeid.is_empty()
459        };
460        if already_registered {
461            // Caller invoked us a second time (e.g. App::new
462            // called init, then a per-feature helper also called
463            // it). Skip the walk -- every option already lives
464            // in the registry from the first call.
465            return;
466        }
467        for decl in OPTION_DECLS.iter() {
468            (decl.register_fn)(self);
469        }
470    }
471
472    /// Wait-free typed read.
473    ///
474    /// Returns `Arc<T>`; `Arc::clone` is one atomic increment.
475    /// Returns the option's default-via-clone fallback if the
476    /// handle's type or index doesn't match -- safer than
477    /// panicking, callers can detect via [`Self::try_get`] if they
478    /// care to. Handles obtained from [`Self::register`] are
479    /// always type-correct, so this only matters for forged ones.
480    #[track_caller]
481    pub fn get<T: OptionType>(&self, handle: OptionHandle<T>) -> Arc<T> {
482        self.try_get(handle).expect("config: invalid handle in get")
483    }
484
485    /// Fallible typed read. Returns `None` if the handle's index
486    /// is out of range or refers to an option of a different `T`.
487    pub fn try_get<T: OptionType>(&self, handle: OptionHandle<T>) -> std::option::Option<Arc<T>> {
488        let arc = self.erased_at(handle.idx)?;
489        let opt = arc.as_any().downcast_ref::<Option<T>>()?;
490        Some(opt.get())
491    }
492
493    /// Closure-style read. Avoids the `Arc::clone` cost of
494    /// [`Self::get`] for one-shot reads.
495    #[track_caller]
496    pub fn with<T: OptionType, R>(&self, handle: OptionHandle<T>, f: impl FnOnce(&T) -> R) -> R {
497        let arc = self
498            .erased_at(handle.idx)
499            .expect("config: handle index out of bounds in with");
500        let opt = arc
501            .as_any()
502            .downcast_ref::<Option<T>>()
503            .expect("config: handle type mismatch in with");
504        opt.with(f)
505    }
506
507    /// Typed write through a handle. Runs the option's validator
508    /// before committing. Publishes [`Event::OptionChanged`] on
509    /// success.
510    pub fn set<T: OptionType>(&self, handle: OptionHandle<T>, value: T) -> Result<(), String> {
511        let arc = self
512            .erased_at(handle.idx)
513            .ok_or_else(|| format!("config: handle index {} out of bounds", handle.idx))?;
514        let opt = arc.as_any().downcast_ref::<Option<T>>().ok_or_else(|| {
515            format!(
516                "config: handle index {} type mismatch (expected {})",
517                handle.idx,
518                T::type_label()
519            )
520        })?;
521        let old = opt.with(|v| v.format());
522        let name = opt.name();
523        opt.set(value)?;
524        self.publish_change(name, Some(old));
525        Ok(())
526    }
527
528    /// Look up an option by name (or alias). Returns the erased
529    /// view -- the by-name path is for cmdline / customize / plugin
530    /// introspection where the type isn't known at compile time.
531    pub fn lookup(&self, name: &str) -> std::option::Option<Arc<dyn ErasedOption>> {
532        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
533        inner
534            .by_name
535            .get(name)
536            .and_then(|i| inner.by_id[*i].as_ref().map(Arc::clone))
537    }
538
539    /// Read a `bool`-typed option's current value by name.
540    /// Returns `None` if the option doesn't exist, isn't boolean,
541    /// or its erased current value fails to downcast to `bool`
542    /// (last case is defense-in-depth -- shouldn't happen given
543    /// `is_bool()` agreement). Used by the host's mode-mirror
544    /// cascade (see `Mode::mirrors_option`) so display modes can
545    /// stay in sync with their typed-option counterparts without
546    /// hardcoded per-mode special cases in the cascade handler.
547    pub fn get_bool_by_name(&self, name: &str) -> std::option::Option<bool> {
548        let spec = self.lookup(name)?;
549        if !spec.is_bool() {
550            return None;
551        }
552        let erased = spec.current_value_erased();
553        erased.downcast_ref::<bool>().copied()
554    }
555
556    /// Read an `i64`-typed option's current value by name. Returns
557    /// `None` if the option doesn't exist or its erased current value
558    /// isn't an `i64` (the downcast IS the type check — there's no
559    /// `is_int` predicate, and a wrong-type option simply fails the
560    /// downcast). The `i64` shape covers every integer option (the
561    /// `options!` macro stores counts / sizes as `i64`). Used by
562    /// subsystems that read a numeric option by name without importing
563    /// its decl type — e.g. `lattice-diff`'s `UnchangedFoldSource`
564    /// reading `ui.diff.context`.
565    pub fn get_int_by_name(&self, name: &str) -> std::option::Option<i64> {
566        let spec = self.lookup(name)?;
567        let erased = spec.current_value_erased();
568        erased.downcast_ref::<i64>().copied()
569    }
570
571    /// Read a `String`-typed option's current value by name. The downcast IS
572    /// the type check, as with [`Self::get_int_by_name`]. Used by subsystems
573    /// that read an enum-ish option by name without importing its decl type —
574    /// e.g. the host reading a plugin's `trim-scope`, which is registered
575    /// dynamically and therefore has no decl type to import.
576    pub fn get_string_by_name(&self, name: &str) -> std::option::Option<String> {
577        let spec = self.lookup(name)?;
578        let erased = spec.current_value_erased();
579        erased.downcast_ref::<String>().cloned()
580    }
581
582    /// Iterate every registered option in registration order.
583    /// Used by completion (`gen:options`) and the customize buffer
584    /// view to enumerate.
585    pub fn iter(&self) -> Vec<Arc<dyn ErasedOption>> {
586        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
587        // `flatten` skips tombstoned (unregistered) slots (PH7.12b).
588        inner.by_id.iter().flatten().map(Arc::clone).collect()
589    }
590
591    /// Number of registered options. Counts live options only — a
592    /// tombstoned (unregistered) slot does not count (PH7.12b).
593    pub fn len(&self) -> usize {
594        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
595        inner.by_id.iter().filter(|o| o.is_some()).count()
596    }
597
598    /// `true` when no live option is registered (tombstoned slots do
599    /// not count).
600    pub fn is_empty(&self) -> bool {
601        self.len() == 0
602    }
603
604    /// Remove an option by name (or any alias), the teardown seam for a plugin
605    /// reload / unload (PH7.12b). Drops every name+alias mapping pointing at the
606    /// option's slot plus any `TypeId` mapping, tombstones the `by_id` slot
607    /// (freeing the `Arc`), and recycles the index through the free-list so a
608    /// re-register reuses it. Idempotent: `false` if nothing is registered under
609    /// `name`. Driven by the plugin host's teardown bundle with the option names
610    /// a plugin registered (`PluginState::config_contributions`); built-in
611    /// options have no unload path in practice. Any live [`OptionHandle`] for
612    /// the removed option is left dangling by contract — its slot is `None`, so
613    /// a by-handle read returns nothing rather than another option's value; the
614    /// index is only ever reused by a *new* registration, never silently
615    /// re-pointed under an existing handle.
616    pub fn unregister(&self, name: &str) -> bool {
617        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
618        let idx = match inner.by_name.get(name) {
619            Some(&i) => i,
620            None => return false,
621        };
622        inner.by_name.retain(|_, v| *v != idx);
623        inner.by_typeid.retain(|_, v| *v != idx);
624        inner.by_id[idx] = None;
625        inner.free_list.push(idx);
626        true
627    }
628
629    /// OC.11c: record that an assignment to `name` failed.
630    ///
631    /// `name` is canonicalised here so an alias (`:set ts=999`) records under
632    /// the name the option was declared with — a plugin asking about its own
633    /// config would never think to ask about an alias, and an alias-keyed
634    /// record is invisible to every reader.
635    pub fn record_failed_assignment(
636        &self,
637        name: &str,
638        message: String,
639        source: std::option::Option<std::path::PathBuf>,
640    ) {
641        let canonical = self
642            .lookup(name)
643            .map(|o| o.name().to_string())
644            .unwrap_or_else(|| name.to_string());
645        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
646        inner
647            .diagnostics
648            .insert(canonical, ConfigDiagnostic { message, source });
649    }
650
651    /// OC.11c: forget any failure recorded for `name` — an assignment to it
652    /// has since succeeded, so the record describes something untrue.
653    pub fn clear_failed_assignment(&self, name: &str) {
654        let canonical = self
655            .lookup(name)
656            .map(|o| o.name().to_string())
657            .unwrap_or_else(|| name.to_string());
658        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
659        inner.diagnostics.remove(&canonical);
660    }
661
662    /// OC.11c: drop every recorded failure.
663    ///
664    /// Called at the start of a config LOAD, which is a fresh reading of the
665    /// whole file: an option whose failing line the user deleted produces no
666    /// message at all, so a per-message update would leave its diagnostic
667    /// behind forever.
668    pub fn clear_all_failed_assignments(&self) {
669        let mut inner = self.inner.lock().expect("ConfigRegistry poisoned");
670        inner.diagnostics.clear();
671    }
672
673    /// OC.11c: the failure recorded against `name`, if the last assignment to
674    /// it failed.
675    ///
676    /// `None` means the last assignment succeeded or there never was one —
677    /// not distinguished, deliberately: the caller's question is "can I trust
678    /// this value", and both answers are yes.
679    pub fn failed_assignment(&self, name: &str) -> std::option::Option<ConfigDiagnostic> {
680        let canonical = self
681            .lookup(name)
682            .map(|o| o.name().to_string())
683            .unwrap_or_else(|| name.to_string());
684        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
685        inner.diagnostics.get(&canonical).cloned()
686    }
687
688    /// Drive the cmdline `:set` syntax against the registry. Parses
689    /// the input through [`parse_set`], then dispatches to the
690    /// matching option's parse / set / negate / format path.
691    /// Returns the echo line on success (`canonical-name=value`) — the
692    /// caller surfaces it via the cmdline echo. Forms:
693    /// - `:set foo` -- echoes `foo=current` for non-bool, sets
694    ///   true for bool (vim convention).
695    /// - `:set nofoo` -- sets bool to false; [`ConfigError::NotBoolean`]
696    ///   for any other type.
697    /// - `:set foo=value` -- parses + validates + sets.
698    /// - `:set foo?` -- always echoes the current value.
699    /// - `:set foo&` -- resets to the registered default.
700    ///
701    /// Every successful write publishes [`Event::OptionChanged`]; a
702    /// query does not. The outcome also maintains the failed-assignment
703    /// record ([`Self::failed_assignment`]): a failure on a known option
704    /// is recorded (with no source file), a success clears it, and an
705    /// unknown name records nothing.
706    ///
707    /// # Examples
708    ///
709    /// ```
710    /// use lattice_config::{ConfigError, ConfigRegistry};
711    ///
712    /// let reg = ConfigRegistry::new();
713    /// reg.init_from_linkme();
714    ///
715    /// assert_eq!(reg.parse_and_set_command("ts=8").unwrap(), "tabstop=8");
716    /// assert_eq!(reg.parse_and_set_command("wrap").unwrap(), "wrap=true");
717    /// assert_eq!(reg.parse_and_set_command("nowrap").unwrap(), "wrap=false");
718    /// assert_eq!(reg.parse_and_set_command("tabstop&").unwrap(), "tabstop=4");
719    ///
720    /// assert!(matches!(
721    ///     reg.parse_and_set_command("notabstop"),
722    ///     Err(ConfigError::NotBoolean(_))
723    /// ));
724    /// assert!(matches!(
725    ///     reg.parse_and_set_command("nosuch=1"),
726    ///     Err(ConfigError::UnknownOption(_))
727    /// ));
728    ///
729    /// // A rejected write is remembered against the canonical name...
730    /// assert!(reg.parse_and_set_command("ts=0").is_err());
731    /// assert!(reg.failed_assignment("tabstop").is_some());
732    /// // ...until a later write succeeds.
733    /// reg.parse_and_set_command("tabstop=2").unwrap();
734    /// assert!(reg.failed_assignment("tabstop").is_none());
735    /// ```
736    pub fn parse_and_set_command(&self, input: &str) -> Result<String, ConfigError> {
737        let parsed = parse_set(input).map_err(ConfigError::Parse)?;
738        // OC.11c: the outcome of THIS assignment replaces whatever was
739        // recorded for the option, at the one chokepoint every `:set` goes
740        // through — so no caller has to remember to clear it. An
741        // `UnknownOption` records nothing: there is no option for a diagnostic
742        // to be about, and keying one under a typo would let it shadow the
743        // real option a plugin later asks about.
744        let touched = match &parsed {
745            ParsedSet::Assign { name, .. } => Some(name.clone()),
746            ParsedSet::Negate(name) | ParsedSet::NameOnly(name) | ParsedSet::Reset(name) => {
747                Some(name.clone())
748            }
749            ParsedSet::Query(_) => None,
750        }
751        .filter(|n| !n.is_empty());
752        let outcome = self.parse_and_set_command_inner(parsed);
753        if let Some(name) = touched {
754            match &outcome {
755                Ok(_) => self.clear_failed_assignment(&name),
756                Err(ConfigError::UnknownOption(_)) => {}
757                Err(err) => self.record_failed_assignment(&name, err.to_string(), None),
758            }
759        }
760        outcome
761    }
762
763    fn parse_and_set_command_inner(&self, parsed: ParsedSet) -> Result<String, ConfigError> {
764        match parsed {
765            ParsedSet::NameOnly(name) => {
766                let opt = self
767                    .lookup(&name)
768                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
769                if opt.is_bool() {
770                    let canonical = opt.name();
771                    let old = opt.get_formatted();
772                    opt.parse_and_set("true").map_err(ConfigError::Validation)?;
773                    self.publish_change(canonical, Some(old));
774                }
775                // For both bool (post-toggle) and non-bool, echo
776                // the current formatted value.
777                Ok(format!("{}={}", opt.name(), opt.get_formatted()))
778            }
779            ParsedSet::Negate(name) => {
780                let opt = self
781                    .lookup(&name)
782                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
783                if !opt.is_bool() {
784                    // Surface the legacy vim wording (`E474: not a
785                    // boolean option`) directly. The bare
786                    // `negate()` path produces a more verbose
787                    // message that's useful in tests but doesn't
788                    // match what users saw before the typed-options
789                    // migration.
790                    return Err(ConfigError::NotBoolean(name));
791                }
792                let canonical = opt.name();
793                let old = opt.get_formatted();
794                opt.negate().map_err(ConfigError::Validation)?;
795                self.publish_change(canonical, Some(old));
796                Ok(format!("{}={}", opt.name(), opt.get_formatted()))
797            }
798            ParsedSet::Assign { name, value } => {
799                let opt = self
800                    .lookup(&name)
801                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
802                let canonical = opt.name();
803                let old = opt.get_formatted();
804                opt.parse_and_set(&value).map_err(ConfigError::Validation)?;
805                self.publish_change(canonical, Some(old));
806                Ok(format!("{}={}", opt.name(), opt.get_formatted()))
807            }
808            ParsedSet::Query(name) => {
809                let opt = self
810                    .lookup(&name)
811                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
812                Ok(format!("{}={}", opt.name(), opt.get_formatted()))
813            }
814            ParsedSet::Reset(name) => {
815                let opt = self
816                    .lookup(&name)
817                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
818                let canonical = opt.name();
819                let old = opt.get_formatted();
820                // Reset to the registered default (the string stored at
821                // registration time from `default_formatted`).
822                opt.parse_and_set(opt.default_formatted())
823                    .map_err(ConfigError::Validation)?;
824                self.publish_change(canonical, Some(old));
825                Ok(format!("{}={}", opt.name(), opt.get_formatted()))
826            }
827        }
828    }
829
830    /// Parse an option spec string for use as a buffer-local override,
831    /// without writing to the global registry. Returns the triple
832    /// `(TypeId, erased_value, canonical_name)` needed to construct an
833    /// [`crate::OptionOverride`] for the buffer-local layer.
834    ///
835    /// - `NameOnly(name)` — for bool options, returns erased `true`.
836    ///   For non-bool, returns `Err` (callers echo the value instead).
837    /// - `Negate(name)` — returns erased `false` (rejects non-bool via
838    ///   the option's own error message).
839    /// - `Assign { name, value }` — parses `value` against the option's
840    ///   [`crate::OptionType`] and validates without writing.
841    /// - `Query(name)` — always returns [`ConfigError::QueryNotAllowed`];
842    ///   callers use `:setlocal name?` echo path instead.
843    /// - `Reset(name)` — returns [`ConfigError::QueryNotAllowed`]; the
844    ///   caller's `:setlocal name&` clear-override path handles it before
845    ///   reaching here.
846    ///
847    /// # Examples
848    ///
849    /// ```
850    /// use std::any::TypeId;
851    /// use lattice_config::{ConfigRegistry, OptionOverride, Tabstop};
852    ///
853    /// let reg = ConfigRegistry::new();
854    /// reg.init_from_linkme();
855    ///
856    /// let (type_id, value, name) = reg.parse_for_buffer_local("ts=2").unwrap();
857    /// assert_eq!(type_id, TypeId::of::<Tabstop>());
858    /// assert_eq!(name, "tabstop");
859    /// assert_eq!(value.downcast_ref::<i64>(), Some(&2));
860    /// // The global value is untouched.
861    /// assert_eq!(*reg.get_typed::<Tabstop>().unwrap(), 4);
862    ///
863    /// let ov = OptionOverride { option_type_id: type_id, value, priority: Default::default() };
864    /// assert_eq!(ov.downcast_value::<i64>(), Some(&2));
865    /// ```
866    pub fn parse_for_buffer_local(
867        &self,
868        input: &str,
869    ) -> Result<
870        (
871            std::any::TypeId,
872            std::sync::Arc<dyn std::any::Any + Send + Sync>,
873            String,
874        ),
875        ConfigError,
876    > {
877        let parsed = parse_set(input).map_err(ConfigError::Parse)?;
878        match parsed {
879            ParsedSet::NameOnly(name) => {
880                let opt = self
881                    .lookup(&name)
882                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
883                if !opt.is_bool() {
884                    return Err(ConfigError::Parse(format!(
885                        "E474: use :setlocal {name}=value to set a non-boolean option"
886                    )));
887                }
888                let type_id = self.typeid_for_name(opt.name())?;
889                let erased = opt
890                    .parse_to_erased("true")
891                    .map_err(ConfigError::Validation)?;
892                Ok((type_id, erased, opt.name().to_string()))
893            }
894            ParsedSet::Negate(name) => {
895                let opt = self
896                    .lookup(&name)
897                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
898                if !opt.is_bool() {
899                    return Err(ConfigError::NotBoolean(name));
900                }
901                let type_id = self.typeid_for_name(opt.name())?;
902                let erased = opt
903                    .parse_to_erased("false")
904                    .map_err(ConfigError::Validation)?;
905                Ok((type_id, erased, opt.name().to_string()))
906            }
907            ParsedSet::Assign { name, value } => {
908                let opt = self
909                    .lookup(&name)
910                    .ok_or(ConfigError::UnknownOption(name.clone()))?;
911                let type_id = self.typeid_for_name(opt.name())?;
912                let erased = opt
913                    .parse_to_erased(&value)
914                    .map_err(ConfigError::Validation)?;
915                Ok((type_id, erased, opt.name().to_string()))
916            }
917            ParsedSet::Query(name) | ParsedSet::Reset(name) => {
918                Err(ConfigError::QueryNotAllowed(name))
919            }
920        }
921    }
922
923    /// Internal helper: look up the `TypeId` registered for an option's
924    /// canonical name. Returns `ConfigError::UnknownOption` if the
925    /// option was registered without a typeid (e.g. via bare
926    /// `try_register` rather than `register_with_typeid`).
927    fn typeid_for_name(&self, canonical: &str) -> Result<std::any::TypeId, ConfigError> {
928        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
929        let &idx = inner
930            .by_name
931            .get(canonical)
932            .ok_or_else(|| ConfigError::UnknownOption(canonical.to_string()))?;
933        inner
934            .by_typeid
935            .iter()
936            .find(|(_, i)| **i == idx)
937            .map(|(tid, _)| *tid)
938            .ok_or_else(|| ConfigError::UnknownOption(canonical.to_string()))
939    }
940
941    fn erased_at(&self, idx: usize) -> std::option::Option<Arc<dyn ErasedOption>> {
942        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
943        inner
944            .by_id
945            .get(idx)
946            .and_then(|slot| slot.as_ref())
947            .map(Arc::clone)
948    }
949}
950
951impl std::fmt::Debug for ConfigRegistry {
952    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
953        let inner = self.inner.lock().expect("ConfigRegistry poisoned");
954        f.debug_struct("ConfigRegistry")
955            .field("count", &inner.by_id.iter().filter(|o| o.is_some()).count())
956            .finish_non_exhaustive()
957    }
958}
959
960#[cfg(test)]
961mod tests {
962    #![allow(clippy::unwrap_used, clippy::panic)]
963    use super::*;
964
965    #[test]
966    fn register_returns_typed_handle_and_get_round_trips() {
967        let r = ConfigRegistry::new();
968        let h: OptionHandle<i64> = r.register(Option::new("ts", 8, "tab width"));
969        assert_eq!(*r.get(h), 8);
970        r.set(h, 4).unwrap();
971        assert_eq!(*r.get(h), 4);
972    }
973
974    #[test]
975    fn register_panics_on_duplicate_name() {
976        let r = ConfigRegistry::new();
977        r.register(Option::<bool>::new("number", true, ""));
978        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
979            r.register(Option::<bool>::new("number", false, ""));
980        }));
981        assert!(result.is_err());
982    }
983
984    #[test]
985    fn lookup_resolves_aliases_to_same_spec() {
986        let r = ConfigRegistry::new();
987        r.register(
988            Option::<i64>::builder("tabstop", 8, "tab")
989                .aliases(&["ts"])
990                .build(),
991        );
992        let canonical = r.lookup("tabstop").unwrap();
993        let aliased = r.lookup("ts").unwrap();
994        assert_eq!(canonical.name(), aliased.name());
995    }
996
997    #[test]
998    fn parse_and_set_command_assign_int() {
999        let r = ConfigRegistry::new();
1000        let h = r.register(Option::<i64>::new("tabstop", 8, ""));
1001        let echo = r.parse_and_set_command("tabstop=4").unwrap();
1002        assert_eq!(echo, "tabstop=4");
1003        assert_eq!(*r.get(h), 4);
1004    }
1005
1006    #[test]
1007    fn unregister_removes_name_and_aliases_and_recycles_the_slot() {
1008        let r = ConfigRegistry::new();
1009        // Two options, one with an alias; capture the aliased option's slot idx.
1010        let keep = r.register(Option::<i64>::new("keep", 1, "sibling"));
1011        let doomed = r.register(
1012            Option::<i64>::builder("plugin.opt", 8, "a plugin option")
1013                .aliases(&["po"])
1014                .build(),
1015        );
1016        let doomed_idx = doomed.idx;
1017        assert_eq!(r.len(), 2);
1018        assert!(r.lookup("plugin.opt").is_some());
1019        assert!(r.lookup("po").is_some());
1020
1021        // Unregister by ALIAS: canonical name + alias both go, the slot frees,
1022        // len drops, the sibling is untouched.
1023        assert!(r.unregister("po"));
1024        assert_eq!(r.len(), 1);
1025        assert!(r.lookup("plugin.opt").is_none());
1026        assert!(r.lookup("po").is_none());
1027        assert_eq!(*r.get(keep), 1);
1028
1029        // Idempotent: a second unload (canonical or alias) removes nothing.
1030        assert!(!r.unregister("plugin.opt"));
1031        assert!(!r.unregister("po"));
1032
1033        // Reload: re-registering reuses the freed slot rather than growing by_id.
1034        let reloaded = r.register(Option::<i64>::new("plugin.opt", 9, "reloaded"));
1035        assert_eq!(reloaded.idx, doomed_idx, "freed slot is recycled");
1036        assert_eq!(r.len(), 2);
1037        assert_eq!(*r.get(reloaded), 9);
1038    }
1039
1040    #[test]
1041    fn parse_and_set_command_negate_bool() {
1042        let r = ConfigRegistry::new();
1043        let h = r.register(Option::<bool>::new("number", true, ""));
1044        let echo = r.parse_and_set_command("nonumber").unwrap();
1045        assert!(!*r.get(h));
1046        assert_eq!(echo, "number=false");
1047    }
1048
1049    #[test]
1050    fn parse_and_set_command_name_only_toggles_bool_on() {
1051        let r = ConfigRegistry::new();
1052        let h = r.register(Option::<bool>::new("number", false, ""));
1053        let echo = r.parse_and_set_command("number").unwrap();
1054        assert!(*r.get(h));
1055        assert_eq!(echo, "number=true");
1056    }
1057
1058    #[test]
1059    fn parse_and_set_command_name_only_echoes_non_bool() {
1060        let r = ConfigRegistry::new();
1061        r.register(Option::<i64>::new("tabstop", 8, ""));
1062        let echo = r.parse_and_set_command("tabstop").unwrap();
1063        assert_eq!(echo, "tabstop=8");
1064    }
1065
1066    #[test]
1067    fn parse_and_set_command_unknown_option() {
1068        let r = ConfigRegistry::new();
1069        let err = r.parse_and_set_command("xyzzy").unwrap_err();
1070        assert!(matches!(err, ConfigError::UnknownOption(_)));
1071    }
1072
1073    #[test]
1074    fn parse_and_set_command_query_form() {
1075        let r = ConfigRegistry::new();
1076        r.register(Option::<bool>::new("number", true, ""));
1077        let echo = r.parse_and_set_command("number?").unwrap();
1078        assert_eq!(echo, "number=true");
1079    }
1080
1081    #[test]
1082    fn validator_runs_through_parse_and_set() {
1083        let r = ConfigRegistry::new();
1084        r.register(
1085            Option::<i64>::builder("tabstop", 8, "")
1086                .validate(|i| {
1087                    if (1..=32).contains(i) {
1088                        Ok(())
1089                    } else {
1090                        Err(format!("out of range: {i}"))
1091                    }
1092                })
1093                .build(),
1094        );
1095        let err = r.parse_and_set_command("tabstop=99").unwrap_err();
1096        assert!(matches!(err, ConfigError::Validation(_)));
1097    }
1098
1099    #[test]
1100    fn iter_returns_options_in_registration_order() {
1101        let r = ConfigRegistry::new();
1102        r.register(Option::<bool>::new("a", true, ""));
1103        r.register(Option::<i64>::new("b", 0, ""));
1104        r.register(Option::<bool>::new("c", false, ""));
1105        // PL8.F: bind the `iter()` temp so the `&str` names (now borrowed from
1106        // each Arc's `Cow` field, not `'static`) outlive the collect.
1107        let opts = r.iter();
1108        let names: Vec<&str> = opts.iter().map(|o| o.name()).collect();
1109        assert_eq!(names, vec!["a", "b", "c"]);
1110    }
1111
1112    #[test]
1113    fn type_mismatch_in_get_panics() {
1114        let r = ConfigRegistry::new();
1115        let h_int: OptionHandle<i64> = r.register(Option::new("ts", 8, ""));
1116        // Forge a handle of the wrong type pointing at the same idx.
1117        let h_bool: OptionHandle<bool> = OptionHandle::new(h_int.raw());
1118        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1119            let _ = r.get(h_bool);
1120        }));
1121        assert!(result.is_err());
1122    }
1123
1124    // ---- Event::OptionChanged publish (DESIGN.md §5.10 + §5.12) ----
1125
1126    fn capture_events() -> (
1127        EventPublisher,
1128        Arc<std::sync::Mutex<Vec<lattice_protocol::Event>>>,
1129    ) {
1130        let captured = Arc::new(std::sync::Mutex::new(Vec::new()));
1131        let cap = captured.clone();
1132        let publisher: EventPublisher = Arc::new(move |event| {
1133            cap.lock().expect("captured poisoned").push(event);
1134        });
1135        (publisher, captured)
1136    }
1137
1138    #[test]
1139    fn typed_set_publishes_option_changed_event() {
1140        use lattice_protocol::Event;
1141        let r = ConfigRegistry::new();
1142        let h = r.register(Option::<bool>::new("number", true, ""));
1143        let (publisher, captured) = capture_events();
1144        r.set_event_publisher(publisher);
1145        r.set(h, false).unwrap();
1146        let events = captured.lock().unwrap();
1147        assert_eq!(events.len(), 1);
1148        match &events[0] {
1149            Event::OptionChanged { name, old, new } => {
1150                assert_eq!(name, "number");
1151                assert_eq!(old.as_deref(), Some("true"));
1152                assert_eq!(new, "false");
1153            }
1154            other => panic!("expected OptionChanged, got {other:?}"),
1155        }
1156    }
1157
1158    #[test]
1159    fn parse_and_set_command_publishes_for_assign() {
1160        use lattice_protocol::Event;
1161        let r = ConfigRegistry::new();
1162        r.register(Option::<i64>::new("tabstop", 8, ""));
1163        let (publisher, captured) = capture_events();
1164        r.set_event_publisher(publisher);
1165        r.parse_and_set_command("tabstop=4").unwrap();
1166        let events = captured.lock().unwrap();
1167        assert_eq!(events.len(), 1);
1168        match &events[0] {
1169            Event::OptionChanged { name, old, new } => {
1170                assert_eq!(name, "tabstop");
1171                assert_eq!(old.as_deref(), Some("8"));
1172                assert_eq!(new, "4");
1173            }
1174            other => panic!("expected OptionChanged, got {other:?}"),
1175        }
1176    }
1177
1178    #[test]
1179    fn parse_and_set_command_publishes_for_negate() {
1180        use lattice_protocol::Event;
1181        let r = ConfigRegistry::new();
1182        r.register(Option::<bool>::new("number", true, ""));
1183        let (publisher, captured) = capture_events();
1184        r.set_event_publisher(publisher);
1185        r.parse_and_set_command("nonumber").unwrap();
1186        let events = captured.lock().unwrap();
1187        assert_eq!(events.len(), 1);
1188        match &events[0] {
1189            Event::OptionChanged { name, old, new } => {
1190                assert_eq!(name, "number");
1191                assert_eq!(old.as_deref(), Some("true"));
1192                assert_eq!(new, "false");
1193            }
1194            other => panic!("expected OptionChanged, got {other:?}"),
1195        }
1196    }
1197
1198    #[test]
1199    fn parse_and_set_command_publishes_for_bool_toggle() {
1200        use lattice_protocol::Event;
1201        let r = ConfigRegistry::new();
1202        r.register(Option::<bool>::new("number", false, ""));
1203        let (publisher, captured) = capture_events();
1204        r.set_event_publisher(publisher);
1205        r.parse_and_set_command("number").unwrap();
1206        let events = captured.lock().unwrap();
1207        assert_eq!(events.len(), 1);
1208        match &events[0] {
1209            Event::OptionChanged { name, old, new } => {
1210                assert_eq!(name, "number");
1211                assert_eq!(old.as_deref(), Some("false"));
1212                assert_eq!(new, "true");
1213            }
1214            other => panic!("expected OptionChanged, got {other:?}"),
1215        }
1216    }
1217
1218    #[test]
1219    fn parse_and_set_command_query_does_not_publish() {
1220        let r = ConfigRegistry::new();
1221        r.register(Option::<bool>::new("number", true, ""));
1222        let (publisher, captured) = capture_events();
1223        r.set_event_publisher(publisher);
1224        r.parse_and_set_command("number?").unwrap();
1225        assert!(captured.lock().unwrap().is_empty());
1226    }
1227
1228    #[test]
1229    fn no_publisher_set_means_no_events() {
1230        let r = ConfigRegistry::new();
1231        let h = r.register(Option::<bool>::new("number", true, ""));
1232        // Don't set a publisher.
1233        r.set(h, false).unwrap();
1234        // No panic, no event capture; just a silent set.
1235        assert!(!*r.get(h));
1236    }
1237
1238    #[test]
1239    fn alias_set_publishes_under_canonical_name() {
1240        use lattice_protocol::Event;
1241        // `:set ts=4` (alias) should publish OptionChanged with
1242        // canonical name "tabstop", not "ts".
1243        let r = ConfigRegistry::new();
1244        r.register(
1245            Option::<i64>::builder("tabstop", 8, "")
1246                .aliases(&["ts"])
1247                .build(),
1248        );
1249        let (publisher, captured) = capture_events();
1250        r.set_event_publisher(publisher);
1251        r.parse_and_set_command("ts=4").unwrap();
1252        let events = captured.lock().unwrap();
1253        assert_eq!(events.len(), 1);
1254        if let Event::OptionChanged { name, .. } = &events[0] {
1255            assert_eq!(name, "tabstop", "expected canonical name");
1256        } else {
1257            panic!("expected OptionChanged");
1258        }
1259    }
1260
1261    #[test]
1262    fn validation_error_does_not_publish() {
1263        let r = ConfigRegistry::new();
1264        r.register(
1265            Option::<i64>::builder("tabstop", 8, "")
1266                .validate(|i| {
1267                    if (1..=32).contains(i) {
1268                        Ok(())
1269                    } else {
1270                        Err(format!("out of range: {i}"))
1271                    }
1272                })
1273                .build(),
1274        );
1275        let (publisher, captured) = capture_events();
1276        r.set_event_publisher(publisher);
1277        let _ = r.parse_and_set_command("tabstop=999");
1278        assert!(captured.lock().unwrap().is_empty());
1279    }
1280}
1281
1282/// Runtime-registered options that have no compile-time declaration — i.e.
1283/// plugin options — grouped by their dotted namespace.
1284///
1285/// `:customize` and its completion are both built on the compile-time decl
1286/// slices, which only native options join. A plugin option reaches `:set` and
1287/// `:describe-option` (both read this registry) but would otherwise be absent
1288/// from every browse surface. The namespace IS the group, which is what native
1289/// groups already are (`ai.log` + `ai.log_level` -> `ai`); the host assigns the
1290/// prefix from the plugin id, so a plugin can neither collide with another nor
1291/// squat a native group.
1292pub fn plugin_option_groups(
1293    reg: &ConfigRegistry,
1294) -> std::collections::BTreeMap<String, Vec<Arc<dyn ErasedOption>>> {
1295    let declared: std::collections::HashSet<&'static str> =
1296        crate::OPTION_DECLS.iter().map(|d| d.name).collect();
1297    let mut out: std::collections::BTreeMap<String, Vec<Arc<dyn ErasedOption>>> =
1298        std::collections::BTreeMap::new();
1299    for opt in reg.iter() {
1300        let name = opt.name().to_string();
1301        if declared.contains(name.as_str()) {
1302            continue;
1303        }
1304        // Un-namespaced runtime options are not plugin contributions; skip
1305        // rather than invent a group for them.
1306        let Some((ns, _)) = name.split_once('.') else {
1307            continue;
1308        };
1309        out.entry(ns.to_string()).or_default().push(opt);
1310    }
1311    for opts in out.values_mut() {
1312        opts.sort_by_key(|o| o.name().to_string());
1313    }
1314    out
1315}