Skip to main content

lattice_config/
option_type.rs

1//! Per-type metadata + parse / format / enumerate hooks
2//! (DESIGN.md §5.12).
3//!
4//! Each option's value type implements [`OptionType`]. The trait is
5//! the single source of truth: `parse` validates user input,
6//! `format` round-trips it back to a string, `enumerate` enumerates
7//! valid string forms for completion (`:set foo=<Tab>`),
8//! `name_forms` enumerates *option-name* alternates for completion
9//! of the option NAME itself (vim's `noNAME` for booleans).
10//!
11//! Foreign primitive types (`bool`, `i64`, `String`) impl this in
12//! the crate's own module to avoid orphan-rule problems. Renderer-
13//! specific types (`FoldMethod`, `Color`) impl `OptionType` from
14//! their owning crate using the trait re-exported here.
15
16/// One enumerated value of an [`OptionType`], paired with its
17/// short help text. Returned by [`OptionType::enumerate_with_docs`]
18/// for the cmdline-completion marginalia column (slice
19/// `3c.unify.option-doc-annotator`). `doc` is the empty string
20/// when the type doesn't provide per-value documentation; the
21/// default impl of `enumerate_with_docs` returns one of these per
22/// `enumerate()` form with an empty doc.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct EnumeratedValue {
25    pub form: &'static str,
26    pub doc: &'static str,
27}
28
29/// Surface contract for any value a typed [`crate::option::Option`] can
30/// hold. Implementors describe how their type round-trips through
31/// the user-facing `:set foo=value` syntax.
32pub trait OptionType: Sized + Clone + Send + Sync + 'static {
33    /// Parse the right-hand side of `:set name=value`. Errors are
34    /// surfaced verbatim through the cmdline echo, so the message
35    /// should read well to the user (mention valid forms when the
36    /// space is finite). The parser MUST round-trip with `format`:
37    /// `T::parse(&v.format()) == Ok(v)` for every `v`.
38    fn parse(s: &str) -> Result<Self, String>;
39
40    /// Render the current value back to a string (`:set foo?` echo,
41    /// the customize buffer view, value comparison). Round-trips
42    /// with [`Self::parse`].
43    fn format(&self) -> String;
44
45    /// Short type label for `:describe-option` (`"boolean"`,
46    /// `"foldmethod"`, etc.). Used in help bodies and the customize
47    /// buffer view.
48    fn type_label() -> &'static str;
49
50    /// Optional: enumerate valid string forms for `:set foo=<Tab>`
51    /// completion. `None` means "free-form" (no enumerable space --
52    /// integers, file paths, free strings). The values are
53    /// `&'static str` to keep the completion source allocation-free
54    /// in the common case.
55    fn enumerate() -> Option<Vec<&'static str>> {
56        None
57    }
58
59    /// Optional: enumerate valid string forms WITH per-value doc
60    /// strings for the cmdline-completion marginalia column.
61    /// Default impl wraps [`Self::enumerate`] with empty docs;
62    /// types with rich help override.
63    ///
64    /// Slice `3c.unify.option-doc-annotator`: cmdline completion
65    /// surfaces these as right-aligned marginalia. Example
66    /// (foldmethod):
67    ///   `marker        Fold by markers ({{{...}}})`
68    ///   `indent        Fold by indent level`
69    ///   `manual        User-defined folds only`
70    ///   `syntax        Folds from tree-sitter syntax tree`
71    fn enumerate_with_docs() -> Option<Vec<EnumeratedValue>> {
72        Self::enumerate().map(|forms| {
73            forms
74                .into_iter()
75                .map(|form| EnumeratedValue { form, doc: "" })
76                .collect()
77        })
78    }
79
80    /// Optional: alternative *name* forms this type accepts for the
81    /// option name itself. Used by completion to surface the name
82    /// alongside its negation (`:set nu` / `:set nonu`). Default:
83    /// no extra forms. Booleans return `[format!("no{canonical}")]`.
84    fn name_forms(_canonical: &str) -> Vec<String> {
85        Vec::new()
86    }
87
88    /// Whether this type's option supports `:set noFOO`. `bool`
89    /// returns `true`; everything else `false`. Drives the negate
90    /// path in the cmdline parser.
91    fn is_bool() -> bool {
92        false
93    }
94
95    /// Whether this option's value is list-shaped — i.e. a TOML
96    /// **array** at config-load time should be joined into the
97    /// delimited string [`Self::parse`] accepts, rather than rejected
98    /// as "not applicable to a scalar option". `false` for every
99    /// scalar type (the default); `true` for list types like
100    /// [`crate::ModelineZone`]. Drives `loader::apply_array` (ML.5).
101    /// The `:set` cmdline path is unaffected — it always passes a
102    /// string and never sees a TOML array.
103    fn accepts_list() -> bool {
104        false
105    }
106
107    /// Negation value (only meaningful for [`Self::is_bool`] true).
108    /// Default: returns `Err` -- the registry guards by checking
109    /// `is_bool()` first, so callers that respect the contract
110    /// won't observe this. The `bool` impl returns `Ok(false)`.
111    fn try_negation_value() -> Result<Self, String> {
112        Err(format!(
113            "option type `{}` does not support negation",
114            Self::type_label()
115        ))
116    }
117
118    // ── TC.1: the shape of this type's value, as data ─────────────
119    //
120    // Defaulted, so no existing option declaration changes. A type
121    // that already enumerates its forms for `:set foo=<Tab>` gets an
122    // `enum` schema for free — which is most of the renderer-owned
123    // types — and everything else falls back to the string form it
124    // already round-trips through. Only `bool` and `i64` need to say
125    // anything, and composite types override all three together.
126    //
127    // See `typed-configuration.md` §2.1 for why this is a re-base of
128    // every option rather than a fourth option kind.
129
130    /// Whether [`Self::enumerate`] is the COMPLETE set of valid values
131    /// (a closed enum) or a completion hint over an open space.
132    ///
133    /// Default `false`, and the default is the point.
134    /// [`Self::enumerate`]'s contract is "enumerate valid string forms
135    /// for `:set foo=<Tab>` completion", which several types read as a
136    /// *hint*: [`crate::ModelineZone`] advertises `auto` while
137    /// accepting any comma-separated list, [`crate::ExpandHeight`]
138    /// advertises `half`/`full` while also accepting a bare number, and
139    /// [`crate::RootMarkers`] advertises the default markers while
140    /// accepting any of them. That ambiguity was harmless while
141    /// `enumerate` only fed a completion popup; TC.1 gave it a second
142    /// consumer that draws a conclusion from it, so it has to be named.
143    ///
144    /// Deriving the schema from `enumerate` alone would have described
145    /// three open-ended types as closed sets, and `:customize` would
146    /// then offer a picker of one value for an option that accepts
147    /// arbitrary lists. Opting IN is one line per type and cannot be
148    /// wrong by omission.
149    fn enumerate_is_exhaustive() -> bool {
150        false
151    }
152
153    /// The declared shape of this type's values.
154    ///
155    /// Default: `enum(...)` for a type whose enumeration is closed
156    /// ([`Self::enumerate_is_exhaustive`]), else `scalar(string)` — the
157    /// honest description of a type whose only contract is
158    /// `parse`/`format`.
159    fn schema() -> crate::ConfigSchema {
160        match Self::enumerate() {
161            Some(forms) if Self::enumerate_is_exhaustive() => {
162                crate::ConfigSchema::Enum(forms.into_iter().map(str::to_string).collect())
163            }
164            _ => crate::ConfigSchema::string(),
165        }
166    }
167
168    /// This value as a schema-shaped tree.
169    ///
170    /// Default: the `format()` string, which matches the default
171    /// `schema()`. MUST agree with `schema()` — a type that overrides
172    /// one and not the other is a silently lossy option, which is what
173    /// the round-trip test in this module exists to catch.
174    fn to_value(&self) -> crate::ConfigValue {
175        crate::ConfigValue::Str(self.format())
176    }
177
178    /// Rebuild from a tree. Default: the inverse of [`Self::to_value`],
179    /// deferring to `parse` so a type's validation rules apply on this
180    /// path exactly as they do on `:set`.
181    fn from_value(value: &crate::ConfigValue) -> Result<Self, String> {
182        match value.as_str() {
183            Some(s) => Self::parse(s),
184            None => Err(format!(
185                "expected {}, got {}",
186                Self::type_label(),
187                value.kind_label()
188            )),
189        }
190    }
191}
192
193// --------------------------------------------------------------
194// Primitive impls. These live here (rather than in their owning
195// crate) because the orphan rule forbids `impl OptionType for bool`
196// elsewhere -- we own the trait, the std types are foreign.
197// --------------------------------------------------------------
198
199impl OptionType for bool {
200    fn parse(s: &str) -> Result<bool, String> {
201        // Accept the legacy `on`/`off` / `1`/`yes` / `0`/`no`
202        // forms so existing config files keep parsing; the
203        // user-facing surface (error message, completion)
204        // advertises only `true`/`false` to keep the typing
205        // surface minimal.
206        match s {
207            "true" | "on" | "1" | "yes" => Ok(true),
208            "false" | "off" | "0" | "no" => Ok(false),
209            other => Err(format!("expected boolean (`true`/`false`), got `{other}`")),
210        }
211    }
212
213    fn format(&self) -> String {
214        // Match the existing `:set foo?` echo so the migration to
215        // typed options doesn't change user-visible cmdline output.
216        // `bool::to_string` returns `"true"`/`"false"`; we keep that
217        // exact wording. Vim's actual convention (`number` /
218        // `nonumber` with no `=value` echo) is a follow-up choice
219        // when we restructure echo output.
220        self.to_string()
221    }
222
223    fn type_label() -> &'static str {
224        "boolean"
225    }
226
227    fn enumerate() -> Option<Vec<&'static str>> {
228        // `:set foo=<Tab>` shows only the canonical forms.
229        // The parser still accepts `on`/`off`/`1`/`0`/`yes`/`no`
230        // for back-compat with hand-written config files, but
231        // surfacing four equivalent forms in completion was
232        // confusing -- the popup pretended each was a distinct
233        // value.
234        Some(vec!["true", "false"])
235    }
236
237    fn enumerate_with_docs() -> Option<Vec<EnumeratedValue>> {
238        // Slice `3c.unify.option-docs-builtin`: per-value
239        // marginalia for boolean options. Negation via
240        // `:set noNAME` is handled by `name_forms` above.
241        Some(vec![
242            EnumeratedValue {
243                form: "true",
244                doc: "Enable this option",
245            },
246            EnumeratedValue {
247                form: "false",
248                doc: "Disable this option",
249            },
250        ])
251    }
252
253    fn name_forms(canonical: &str) -> Vec<String> {
254        vec![format!("no{canonical}")]
255    }
256
257    fn is_bool() -> bool {
258        true
259    }
260
261    fn try_negation_value() -> Result<Self, String> {
262        Ok(false)
263    }
264
265    // TC.1: a boolean is a boolean, not the string "true". The default
266    // would have described it as an enum of its two completion forms,
267    // which reads fine and would make `:customize` offer a two-item
268    // picker where a checkbox belongs.
269    fn schema() -> crate::ConfigSchema {
270        crate::ConfigSchema::bool()
271    }
272
273    fn to_value(&self) -> crate::ConfigValue {
274        crate::ConfigValue::Bool(*self)
275    }
276
277    fn from_value(value: &crate::ConfigValue) -> Result<Self, String> {
278        value
279            .as_bool()
280            .ok_or_else(|| format!("expected boolean, got {}", value.kind_label()))
281    }
282}
283
284impl OptionType for i64 {
285    fn parse(s: &str) -> Result<i64, String> {
286        s.parse::<i64>()
287            .map_err(|e| format!("expected integer, got `{s}`: {e}"))
288    }
289
290    fn format(&self) -> String {
291        self.to_string()
292    }
293
294    fn type_label() -> &'static str {
295        "integer"
296    }
297
298    // TC.1: an integer crosses as an integer. The TOML loader is the
299    // caller that cares — `tabstop = 4` is a TOML integer, and turning
300    // it into "4" only to parse it back was the round-trip this removes.
301    fn schema() -> crate::ConfigSchema {
302        crate::ConfigSchema::int()
303    }
304
305    fn to_value(&self) -> crate::ConfigValue {
306        crate::ConfigValue::Int(*self)
307    }
308
309    fn from_value(value: &crate::ConfigValue) -> Result<Self, String> {
310        value
311            .as_int()
312            .ok_or_else(|| format!("expected integer, got {}", value.kind_label()))
313    }
314}
315
316impl OptionType for String {
317    fn parse(s: &str) -> Result<String, String> {
318        Ok(s.to_string())
319    }
320
321    fn format(&self) -> String {
322        self.clone()
323    }
324
325    fn type_label() -> &'static str {
326        "string"
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    #![allow(clippy::unwrap_used, clippy::panic)]
333    use super::*;
334
335    #[test]
336    fn bool_parse_accepts_synonyms() {
337        for s in ["on", "true", "1", "yes"] {
338            assert_eq!(bool::parse(s), Ok(true));
339        }
340        for s in ["off", "false", "0", "no"] {
341            assert_eq!(bool::parse(s), Ok(false));
342        }
343    }
344
345    #[test]
346    fn bool_parse_rejects_garbage_with_helpful_message() {
347        let e = bool::parse("maybe").unwrap_err();
348        assert!(e.contains("expected boolean"), "got `{e}`");
349        assert!(e.contains("maybe"), "got `{e}`");
350    }
351
352    #[test]
353    fn bool_round_trip_through_format_and_parse() {
354        assert_eq!(bool::parse(&true.format()), Ok(true));
355        assert_eq!(bool::parse(&false.format()), Ok(false));
356    }
357
358    #[test]
359    fn bool_format_matches_legacy_true_false_text() {
360        // Migration constraint: `:set foo?` echo must read
361        // identically to the pre-migration cmdline output.
362        assert_eq!(true.format(), "true");
363        assert_eq!(false.format(), "false");
364    }
365
366    #[test]
367    fn bool_name_forms_includes_negation() {
368        assert_eq!(bool::name_forms("number"), vec!["nonumber"]);
369    }
370
371    #[test]
372    fn bool_is_bool_marker() {
373        assert!(bool::is_bool());
374        assert!(!i64::is_bool());
375        assert!(!String::is_bool());
376    }
377
378    #[test]
379    fn bool_negation_value_is_false() {
380        assert_eq!(<bool as OptionType>::try_negation_value(), Ok(false));
381    }
382
383    #[test]
384    fn int_negation_value_returns_err() {
385        assert!(<i64 as OptionType>::try_negation_value().is_err());
386    }
387
388    #[test]
389    fn int_parse_round_trip() {
390        assert_eq!(i64::parse("42"), Ok(42));
391        assert_eq!(i64::parse(&(-7i64).format()), Ok(-7));
392        assert!(i64::parse("not-a-number").is_err());
393    }
394
395    #[test]
396    fn int_no_enumeration() {
397        assert!(i64::enumerate().is_none());
398        assert!(i64::name_forms("tabstop").is_empty());
399    }
400
401    #[test]
402    fn string_pass_through() {
403        assert_eq!(String::parse("hello"), Ok("hello".into()));
404        assert_eq!("hello".to_string().format(), "hello");
405        assert!(String::enumerate().is_none());
406    }
407}