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}