Skip to main content

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}