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}