lattice_config/erased.rs
1//! Type-erased view of [`crate::option::Option<T>`] for the registry to
2//! store heterogeneous specs in a single `Vec`.
3//!
4//! Consumers that know the type at compile time use
5//! [`crate::option::OptionHandle<T>`] for direct typed access. Consumers
6//! that only have a runtime name (cmdline `:set foo=bar`, the
7//! customize buffer view, plugin introspection) go through
8//! [`ErasedOption`].
9
10use std::any::Any;
11use std::sync::Arc;
12
13use crate::option::Option;
14use crate::option_type::OptionType;
15
16/// Type-erased operations every typed [`Option<T>`] supports. The
17/// registry stores `Arc<dyn ErasedOption>` in a `Vec` indexed by
18/// the private `idx` field of [`crate::option::OptionHandle`]; `parse_and_set_by_name`
19/// drives this trait when the user types `:set name=value`.
20///
21/// `as_any` is the canonical Rust idiom for downcasting back to
22/// the concrete `Option<T>` when a typed handle reads. Required
23/// rather than auto-derived so the bound stays explicit.
24pub trait ErasedOption: Send + Sync {
25 /// Canonical name (`tabstop`, `ui.diagnostics.inline`). The name
26 /// [`crate::ConfigRegistry`] keys failures and events under, even
27 /// when the user typed an alias.
28 fn name(&self) -> &str;
29 /// Alternative names that resolve to this option (`ts` for
30 /// `tabstop`). Empty for most options.
31 fn aliases(&self) -> &'static [&'static str];
32 /// Human-readable description, shown by `:describe-option` and
33 /// `:customize`.
34 fn doc(&self) -> &str;
35 /// The value type's [`OptionType::type_label`] (`boolean`,
36 /// `integer`, `string`, `signcolumn`, ...).
37 fn type_label(&self) -> &'static str;
38
39 /// Parse `value` against the option's [`OptionType`], run the
40 /// post-parse validator, store the new value. Returns the
41 /// concatenated error if either stage fails.
42 fn parse_and_set(&self, value: &str) -> Result<(), String>;
43
44 /// Parse `value` against the option's [`OptionType`] and run the
45 /// validator, but **do not write to the option's storage**. Returns
46 /// the typed value erased as `Arc<dyn Any + Send + Sync>`. Used by
47 /// `ConfigRegistry::parse_for_buffer_local` to produce an
48 /// `OptionOverride` for the buffer-local layer (BL.1) without
49 /// touching the global registry.
50 fn parse_to_erased(&self, value: &str) -> Result<Arc<dyn Any + Send + Sync>, String>;
51
52 /// Render the current value (`:set foo?` echo, customize
53 /// buffer view).
54 fn get_formatted(&self) -> String;
55
56 /// Render the *default* value (the value the option held at
57 /// registration time). Used by `:describe-option` to show
58 /// "default: X" alongside "current: Y".
59 fn default_formatted(&self) -> &str;
60
61 /// Enumerate valid string values for `:set foo=<Tab>`. `None`
62 /// means free-form (no completion).
63 fn enumerate_values(&self) -> std::option::Option<Vec<&'static str>>;
64
65 /// Enumerate valid string values + per-value doc strings.
66 /// Slice `3c.unify.option-doc-annotator` — drives the
67 /// marginalia column on `:set foo=<Tab>` completion. `None`
68 /// means free-form. Default impl wraps `enumerate_values`
69 /// with empty docs; rich-help types override on
70 /// `OptionType::enumerate_with_docs`.
71 fn enumerate_values_with_docs(
72 &self,
73 ) -> std::option::Option<Vec<crate::option_type::EnumeratedValue>>;
74
75 /// Alternate name forms the cmdline accepts (`noNAME` for
76 /// booleans). Drives `:set <Tab>` enumeration.
77 fn name_forms(&self) -> Vec<String>;
78
79 /// Whether this option supports `:set noNAME`. Booleans true,
80 /// everything else false.
81 fn is_bool(&self) -> bool;
82
83 /// Whether this option's value is list-shaped (ML.5). The config
84 /// loader consults this to decide whether a TOML **array** for this
85 /// key should be joined into the option's delimited parse form
86 /// (`true`) or rejected as a scalar/array mismatch (`false`).
87 /// Forwards to [`OptionType::accepts_list`].
88 fn accepts_list(&self) -> bool;
89
90 /// Set the option to its negation value (only meaningful when
91 /// [`Self::is_bool`] is true). Used by the `:set noNAME` path.
92 /// Returns `Err` if called on a non-bool option (registry
93 /// guards this; this is defense in depth).
94 fn negate(&self) -> Result<(), String>;
95
96 /// Format an already-erased value of this option's type. Used by
97 /// the query echo path (`:set name?`) to display the resolved value
98 /// when the concrete type isn't known at the call site. Returns
99 /// `None` if `value` doesn't downcast to this option's `Value` type
100 /// (caller falls back to `get_formatted()` in that case).
101 fn format_erased_value(
102 &self,
103 value: &std::sync::Arc<dyn std::any::Any + Send + Sync>,
104 ) -> std::option::Option<String>;
105
106 /// Project back to the concrete type for typed-handle reads.
107 ///
108 /// Implementors return `self`. Crate-private trait methods
109 /// can't be expressed cleanly across the boundary, so we keep
110 /// this in the public surface but document it as
111 /// implementation-detail.
112 fn as_any(&self) -> &dyn Any;
113
114 /// Return the option's *current* value as an erased
115 /// `Arc<dyn Any + Send + Sync>`. Used by
116 /// [`crate::ConfigRegistry::bootstrap_resolved_with_current_values`]
117 /// to seed the [`crate::ResolvedOptions`] cache with each
118 /// option's current registry value (resolution layer 5)
119 /// before mode/buffer-local layers overlay on top.
120 fn current_value_erased(&self) -> Arc<dyn Any + Send + Sync>;
121
122 // ── TC.1: the shape, and values in that shape ─────────────────
123 //
124 // The runtime-name surface every consumer that does not know the
125 // type at compile time already goes through — `:set`, the TOML
126 // loader, plugin introspection, `:describe-option`. Before this it
127 // offered a `type_label(): &str` and a formatted `String`, which is
128 // not enough to render a composite, validate a field, or write a
129 // value back to TOML. See `typed-configuration.md`.
130
131 /// This option's declared shape.
132 fn schema(&self) -> crate::ConfigSchema;
133
134 /// The current value as a schema-shaped tree.
135 fn get_value(&self) -> crate::ConfigValue;
136
137 /// Validate `value` against [`Self::schema`], then commit it.
138 ///
139 /// Validation runs against the SCHEMA first, so the error carries a
140 /// path (`target.file: expected string, got integer`), and only
141 /// then through the type's own conversion and post-parse validator
142 /// — the same two stages `parse_and_set` runs, in the same order,
143 /// so the tree path and the string path cannot disagree about
144 /// whether a value is acceptable.
145 fn set_value(&self, value: &crate::ConfigValue) -> Result<(), String>;
146}
147
148impl<T: OptionType> ErasedOption for Option<T> {
149 fn name(&self) -> &str {
150 &self.name
151 }
152
153 fn aliases(&self) -> &'static [&'static str] {
154 self.aliases
155 }
156
157 fn doc(&self) -> &str {
158 &self.doc
159 }
160
161 fn type_label(&self) -> &'static str {
162 T::type_label()
163 }
164
165 fn parse_and_set(&self, value: &str) -> Result<(), String> {
166 match T::parse(value) {
167 Ok(parsed) => {
168 // TC.7 fix: a COMPOSITE is checked against its declared schema
169 // here, exactly as `set_value` checks one — the two paths must
170 // not disagree about whether a value is acceptable, and before
171 // this they did.
172 //
173 // `T::parse` is not the check for a structured option. Such an
174 // option is a `ConfigOption<ConfigValue>`, so `parse` only
175 // establishes that the text is TOML; nothing looks at the
176 // declared shape. An enum field with a typo therefore STORED
177 // fine and failed later, in the guest, when it re-derived its
178 // typed value — where the failure is per-option and costs the
179 // whole thing. org's `agenda-custom-commands` lost every
180 // command to one bad `when`, silently at both ends.
181 //
182 // Scoped to composites deliberately. For a scalar or an enum,
183 // `T::parse` IS the check and already rejects what this would;
184 // running the validator there could only differ if a type's
185 // `to_value` disagreed with its own schema, and turning that
186 // into a rejected `:set` on every option is a risk with no
187 // matching benefit.
188 let schema = self.declared_schema();
189 if schema.is_composite() {
190 // Named, because a schema error locates a spot INSIDE a
191 // value (`[0].when: …`) and never says which option that
192 // value belongs to. See `a_composites_rejection_names_the_option`.
193 crate::schema::validate(&schema, &parsed.to_value())
194 .map_err(|e| format!("{}: {e}", self.name))?;
195 }
196 self.set(parsed)
197 }
198 // TC.7: a `list<string>` option typed at the command line.
199 //
200 // `:set org.agenda-files=~/org` is a natural thing to type and was
201 // a working one before those options declared list schemas; after,
202 // the text is not TOML and `parse` refuses it. Requiring
203 // `value = ["~/org"]` on a command line would be an implementation
204 // detail leaking into the one surface that is meant to be terse.
205 //
206 // So a failed parse on a string-list option falls back to the ML.5
207 // rule its predecessors used: split on commas. Only on the FAILURE
208 // path, so a well-formed TOML array still means what it says, and
209 // only for `list<string>` — a list of records has no delimited
210 // spelling worth inventing, and the original error is the honest
211 // answer there.
212 //
213 // Newlines separate as well as commas, because a `:set` spec can
214 // arrive from somewhere other than a command line and splitting
215 // only on commas would make one of those two spellings silently
216 // produce a single element containing newlines.
217 Err(err) => match self.declared_schema() {
218 crate::ConfigSchema::List(inner)
219 if matches!(
220 inner.as_ref(),
221 crate::ConfigSchema::Scalar(crate::ScalarKind::Str)
222 ) =>
223 {
224 let items: Vec<crate::ConfigValue> = value
225 .split([',', '\n'])
226 .map(str::trim)
227 .filter(|s| !s.is_empty())
228 .map(|s| crate::ConfigValue::Str(s.to_string()))
229 .collect();
230 self.set_value(&crate::ConfigValue::List(items))
231 }
232 // A composite's parse error is `expected TOML: … line 1,
233 // column 11` — a spot in text the user cannot see, with no
234 // clue which option it was. Name it, for the reason the schema
235 // rejection above is named. Scalars keep their wording
236 // verbatim: those are vim's `E474: …` and already say enough.
237 schema if schema.is_composite() => Err(format!("{}: {err}", self.name)),
238 _ => Err(err),
239 },
240 }
241 }
242
243 fn parse_to_erased(&self, value: &str) -> Result<Arc<dyn Any + Send + Sync>, String> {
244 let parsed = T::parse(value)?;
245 // Run the validator (if any) without writing to storage.
246 if let Some(v) = self.validate
247 && let Err(e) = v(&parsed)
248 {
249 return Err(e);
250 }
251 Ok(Arc::new(parsed) as Arc<dyn Any + Send + Sync>)
252 }
253
254 fn get_formatted(&self) -> String {
255 self.with(|v| v.format())
256 }
257
258 fn default_formatted(&self) -> &str {
259 &self.default_formatted
260 }
261
262 fn enumerate_values(&self) -> std::option::Option<Vec<&'static str>> {
263 T::enumerate()
264 }
265
266 fn enumerate_values_with_docs(
267 &self,
268 ) -> std::option::Option<Vec<crate::option_type::EnumeratedValue>> {
269 T::enumerate_with_docs()
270 }
271
272 fn name_forms(&self) -> Vec<String> {
273 T::name_forms(self.name.as_ref())
274 }
275
276 fn is_bool(&self) -> bool {
277 T::is_bool()
278 }
279
280 fn accepts_list(&self) -> bool {
281 T::accepts_list()
282 }
283
284 fn negate(&self) -> Result<(), String> {
285 let neg = T::try_negation_value()
286 .map_err(|e| format!("option `{}` does not support `:set noNAME`: {e}", self.name))?;
287 self.set(neg)
288 }
289
290 fn format_erased_value(
291 &self,
292 value: &std::sync::Arc<dyn std::any::Any + Send + Sync>,
293 ) -> std::option::Option<String> {
294 value.clone().downcast::<T>().ok().map(|v| v.format())
295 }
296
297 fn as_any(&self) -> &dyn Any {
298 self
299 }
300
301 fn current_value_erased(&self) -> Arc<dyn Any + Send + Sync> {
302 // ArcSwap::load_full returns Arc<T>; coerce to
303 // Arc<dyn Any + Send + Sync> via Rust's unsized
304 // coercion (T: 'static + Send + Sync from OptionType
305 // bounds satisfies Any + Send + Sync).
306 let v: Arc<T> = self.cell.load_full();
307 v
308 }
309
310 fn schema(&self) -> crate::ConfigSchema {
311 // The option's DECLARED shape: what a plugin gave at registration, or
312 // the type's own answer. Not `T::schema()` directly — a structured
313 // plugin option's shape is data, and a static method cannot know it.
314 self.declared_schema()
315 }
316
317 fn get_value(&self) -> crate::ConfigValue {
318 self.with(|v| v.to_value())
319 }
320
321 fn set_value(&self, value: &crate::ConfigValue) -> Result<(), String> {
322 // Schema first: it is the stage that knows WHERE a composite
323 // went wrong. `from_value` can only say that the whole tree is
324 // the wrong shape, which for a list of records is no better
325 // than the hand-rolled parsers this replaces.
326 crate::schema::validate(&self.declared_schema(), value).map_err(|e| e.to_string())?;
327 let parsed = T::from_value(value)?;
328 self.set(parsed)
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 #![allow(clippy::unwrap_used, clippy::panic)]
335 use super::*;
336
337 #[test]
338 fn erased_view_dispatches_through_trait_object() {
339 let o: Option<bool> = Option::new("number", true, "doc");
340 let erased: &dyn ErasedOption = &o;
341 assert_eq!(erased.name(), "number");
342 assert_eq!(erased.type_label(), "boolean");
343 assert!(erased.is_bool());
344 assert_eq!(erased.get_formatted(), "true");
345 erased.parse_and_set("off").unwrap();
346 assert_eq!(erased.get_formatted(), "false");
347 erased.negate().unwrap();
348 assert_eq!(erased.get_formatted(), "false");
349 }
350
351 /// The `:set` path must check a composite against its DECLARED schema.
352 ///
353 /// `set_value` did and `parse_and_set` did not, and the gap is not
354 /// cosmetic: a structured plugin option is `ConfigOption<ConfigValue>`, so
355 /// `T::parse` only checks that the text is TOML. An enum field with a typo
356 /// therefore stored fine and failed later, in the GUEST, when it re-derived
357 /// its typed shape — where the failure is per-OPTION and costs the whole
358 /// value. org's `agenda-custom-commands` lost every command to one bad
359 /// `when`, and the user was told nothing at either end.
360 #[test]
361 fn parse_and_set_validates_a_composite_against_its_schema() {
362 use crate::schema::SchemaField;
363 use crate::{ConfigSchema, ConfigValue, ScalarKind};
364 let schema = ConfigSchema::List(Box::new(ConfigSchema::Record(vec![
365 SchemaField::new("title", ConfigSchema::Scalar(ScalarKind::Str), ""),
366 SchemaField::new(
367 "when",
368 ConfigSchema::Enum(vec!["overdue".into(), "any".into()]),
369 "",
370 ),
371 ])));
372 let o: Option<ConfigValue> =
373 Option::structured("org.sections", schema, ConfigValue::List(Vec::new()), "doc");
374 let before = o.get_formatted();
375 let erased: &dyn ErasedOption = &o;
376
377 let err = erased
378 .parse_and_set("[[value]]\ntitle = \"Bad\"\nwhen = \"someday\"\n")
379 .expect_err("an unknown enum form is refused at `:set`, not later");
380 assert!(
381 err.contains("someday") && err.contains("overdue | any"),
382 "the rejection lists what IS valid — the one thing an enum knows \
383 that a free string does not: {err}"
384 );
385 assert_eq!(
386 erased.get_formatted(),
387 before,
388 "a refused set leaves the previous value alone"
389 );
390
391 erased
392 .parse_and_set("[[value]]\ntitle = \"Good\"\nwhen = \"any\"\n")
393 .expect("a value that fits still sets");
394 }
395
396 /// A composite's rejection names the OPTION, not just the position in it.
397 ///
398 /// `T::parse` for a structured option answers `expected TOML: … line 1,
399 /// column 11`, and the schema answers `[0].when: expected one of …`. Both
400 /// describe a spot inside a value the user cannot see, and neither says
401 /// which of forty options they were setting. A `:set` echo that does not
402 /// name the option is a message the user cannot act on — they have to guess
403 /// which line of their config it came from.
404 ///
405 /// Scalars keep their wording verbatim: those messages are vim's (`E474:
406 /// …`) and already carry what they need.
407 #[test]
408 fn a_composites_rejection_names_the_option() {
409 use crate::schema::SchemaField;
410 use crate::{ConfigSchema, ConfigValue, ScalarKind};
411 let schema = ConfigSchema::List(Box::new(ConfigSchema::Record(vec![SchemaField::new(
412 "key",
413 ConfigSchema::Scalar(ScalarKind::Str),
414 "",
415 )])));
416 let o: Option<ConfigValue> = Option::structured(
417 "org.capture-templates",
418 schema,
419 ConfigValue::List(Vec::new()),
420 "doc",
421 );
422 let erased: &dyn ErasedOption = &o;
423
424 // Not TOML at all — what a half-typed table header produces.
425 let err = erased
426 .parse_and_set("[[template]\nkey = \"t\"")
427 .expect_err("malformed TOML is refused");
428 assert!(
429 err.contains("org.capture-templates"),
430 "the echo names the option at fault: {err}"
431 );
432
433 // TOML, but the wrong shape.
434 let err = erased
435 .parse_and_set("[[value]]\nkey = 7\n")
436 .expect_err("a wrong-typed field is refused");
437 assert!(
438 err.contains("org.capture-templates"),
439 "…and so does a schema rejection: {err}"
440 );
441 }
442
443 #[test]
444 fn erased_negate_rejects_non_bool() {
445 let o: Option<i64> = Option::new("tabstop", 8, "");
446 let erased: &dyn ErasedOption = &o;
447 let err = erased.negate().unwrap_err();
448 assert!(
449 err.contains("does not support `:set noNAME`"),
450 "got `{err}`"
451 );
452 }
453
454 #[test]
455 fn erased_parse_and_set_surfaces_type_errors() {
456 let o: Option<bool> = Option::new("number", true, "");
457 let erased: &dyn ErasedOption = &o;
458 let err = erased.parse_and_set("maybe").unwrap_err();
459 assert!(err.contains("expected boolean"));
460 }
461
462 #[test]
463 fn erased_as_any_downcasts_back_to_concrete_option() {
464 let o: Option<bool> = Option::new("number", true, "");
465 let erased: &dyn ErasedOption = &o;
466 let typed = erased.as_any().downcast_ref::<Option<bool>>();
467 assert!(typed.is_some());
468 let bad = erased.as_any().downcast_ref::<Option<i64>>();
469 assert!(bad.is_none());
470 }
471}
472
473#[cfg(test)]
474mod string_list_set_tests {
475 #![allow(clippy::unwrap_used, clippy::panic)]
476 use super::*;
477 use crate::option::Option as ConfigOption;
478 use crate::{ConfigSchema, ConfigValue};
479
480 fn paths() -> ConfigOption<ConfigValue> {
481 ConfigOption::structured(
482 "org.agenda-files",
483 ConfigSchema::list(ConfigSchema::string()),
484 ConfigValue::List(Vec::new()),
485 "Which files the agenda scans.",
486 )
487 }
488
489 #[test]
490 fn a_bare_path_is_a_one_element_list() {
491 // `:set org.agenda-files=~/org` is a natural thing to type and was a
492 // working one before the option declared a list schema. Requiring
493 // `value = ["~/org"]` on a command line would be an implementation
494 // detail leaking into the surface meant to be terse.
495 let o = paths();
496 let erased: &dyn ErasedOption = &o;
497 erased.parse_and_set("~/org").unwrap();
498 assert_eq!(
499 erased.get_value(),
500 ConfigValue::List(vec![ConfigValue::Str("~/org".into())])
501 );
502 }
503
504 #[test]
505 fn commas_or_newlines_separate_and_blanks_are_dropped() {
506 let o = paths();
507 let erased: &dyn ErasedOption = &o;
508 erased.parse_and_set("~/org, ~/notes.org ,,").unwrap();
509 assert_eq!(
510 erased.get_value(),
511 ConfigValue::List(vec![
512 ConfigValue::Str("~/org".into()),
513 ConfigValue::Str("~/notes.org".into()),
514 ])
515 );
516 // A spec can arrive from somewhere other than a command line.
517 erased.parse_and_set("~/org\n~/notes.org\n").unwrap();
518 assert_eq!(
519 erased.get_value(),
520 ConfigValue::List(vec![
521 ConfigValue::Str("~/org".into()),
522 ConfigValue::Str("~/notes.org".into()),
523 ])
524 );
525 }
526
527 #[test]
528 fn a_well_formed_toml_array_still_means_what_it_says() {
529 // The fallback is on the FAILURE path only. A value that parses as
530 // TOML must never be reinterpreted — `value = [...]` is the form
531 // `format` emits, so a round-trip through `:set foo?` has to survive.
532 let o = paths();
533 let erased: &dyn ErasedOption = &o;
534 erased
535 .parse_and_set("value = [\"~/a\", \"~/b\"]")
536 .expect("the TOML form still works");
537 assert_eq!(
538 erased.get_value(),
539 ConfigValue::List(vec![
540 ConfigValue::Str("~/a".into()),
541 ConfigValue::Str("~/b".into()),
542 ])
543 );
544 // …and the round-trip closes: whatever `format` writes, `parse_and_set`
545 // reads back to the same value.
546 let text = erased.get_formatted();
547 let o2 = paths();
548 let erased2: &dyn ErasedOption = &o2;
549 erased2
550 .parse_and_set(&text)
551 .expect("its own output re-reads");
552 assert_eq!(erased2.get_value(), erased.get_value());
553 }
554
555 #[test]
556 fn a_record_list_keeps_the_parse_error_rather_than_being_split() {
557 // A list of records has no delimited spelling worth inventing, and
558 // splitting one on commas would turn a typo into a list of nonsense
559 // strings that then fails validation somewhere else. The original
560 // error is the honest answer.
561 let o = ConfigOption::<ConfigValue>::structured(
562 "org.capture-templates",
563 ConfigSchema::list(ConfigSchema::record([crate::SchemaField::new(
564 "key",
565 ConfigSchema::string(),
566 "",
567 )])),
568 ConfigValue::List(Vec::new()),
569 "",
570 );
571 let erased: &dyn ErasedOption = &o;
572 let err = erased
573 .parse_and_set("t, n, r")
574 .expect_err("no delimited form");
575 assert!(err.contains("TOML"), "the parse error survives: {err}");
576 }
577}