Skip to main content

hjkl_engine/
options_registry.rs

1//! One table describing every `:set`-able option.
2//!
3//! # Why this exists
4//!
5//! An option's name used to appear in roughly twenty hand-maintained places —
6//! seven tables in `hjkl_ex::setopt` alone (the completion name list, the
7//! boolean list, the enum-value map, the `?` query match, the `name=value`
8//! match, the boolean match, and the `!` toggle match), plus the `Settings` and
9//! `Options` structs, their defaults and converters, and the host's config-key
10//! mapping and TOML writer.
11//!
12//! Nothing kept them in step, and they drifted. Found in one audit:
13//!
14//! - `:set inv<name>` was offered by completion and rejected by the parser, for
15//!   every boolean option — the `inv` prefix had no arm at all.
16//! - `:set <name>!` was implemented for four options and errored for the other
17//!   thirty.
18//! - `:set modifiable?` / `linebreak?` / `motion_sneak?` had no query arm.
19//! - `updatetime`, `colorizer` and `colorizer_filetypes` had `Settings` fields
20//!   and no `:set` name whatsoever.
21//!
22//! Every one of those is a table that was updated while its siblings were not.
23//! [`OPTIONS`] replaces the lot: a name, its aliases, its type, whether it is
24//! global or buffer-local, and a getter/setter pair against [`Settings`]. The
25//! `no…` / `inv…` / `!` / `?` forms are then properties of
26//! [`OptKind::Bool`] rather than four independent lists, so they cannot
27//! disagree.
28//!
29//! # What is deliberately NOT here
30//!
31//! - **`Options` / `OptionsConfig` struct fields.** Both need compile-time
32//!   fields (one is the engine's snapshot type, the other is a `serde`
33//!   schema), so they stay hand-written. Tests pin them against this table
34//!   rather than a macro generating them.
35//! - **Host-owned pseudo-options** — `mouse`, `background`, `endofline`. None
36//!   is a [`Settings`] field; the host intercepts them before the engine is
37//!   consulted.
38
39use crate::editor::Settings;
40use crate::types::{DiagInlineMode, FoldMethod, OptionValue, SignColumnMode};
41
42/// What kind of value an option holds, and what `:set` will accept for it.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum OptKind {
45    /// `:set name`, `:set noname`, `:set invname`, `:set name!`,
46    /// `:set name=on|off|true|false|yes|no|1|0`.
47    Bool,
48    /// `:set name=N`, rejected outside `min..=max`.
49    Int { min: u32, max: u32 },
50    /// `:set name=text`.
51    Str,
52    /// `:set name=one-of`. The slice is also the completion candidate list.
53    Enum(&'static [&'static str]),
54    /// A comma-separated list. Transported as [`OptionValue::String`]; hosts
55    /// that have a richer representation (TOML arrays) split on `,`.
56    StrList,
57}
58
59/// Whether an option describes the session or a single buffer.
60///
61/// [`OptScope::BufferLocal`] options are never written to a config file:
62/// `filetype` is redetected per file and `readonly` is a property of the
63/// buffer at hand, so persisting either would misapply it to every file opened
64/// afterwards.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum OptScope {
67    Global,
68    BufferLocal,
69}
70
71/// One `:set`-able option.
72pub struct OptionDesc {
73    /// Canonical name, as `:set name?` echoes it and as a config key.
74    pub name: &'static str,
75    /// Vim-style short forms (`cul` for `cursorline`).
76    pub aliases: &'static [&'static str],
77    pub kind: OptKind,
78    pub scope: OptScope,
79    /// Read the live value.
80    pub get: fn(&Settings) -> OptionValue,
81    /// Write it, rejecting a value the option cannot hold.
82    pub set: fn(&mut Settings, OptionValue) -> Result<(), String>,
83}
84
85impl OptionDesc {
86    /// True when `name` is this option's canonical name or one of its aliases.
87    pub fn matches(&self, name: &str) -> bool {
88        self.name == name || self.aliases.contains(&name)
89    }
90
91    /// Canonical name plus every alias, for completion candidate lists.
92    pub fn all_names(&self) -> impl Iterator<Item = &'static str> {
93        std::iter::once(self.name).chain(self.aliases.iter().copied())
94    }
95}
96
97/// Find an option by canonical name or alias.
98pub fn lookup(name: &str) -> Option<&'static OptionDesc> {
99    OPTIONS.iter().find(|d| d.matches(name))
100}
101
102// ── value coercion helpers ───────────────────────────────────────────────────
103
104fn want_bool(name: &str, v: OptionValue) -> Result<bool, String> {
105    match v {
106        OptionValue::Bool(b) => Ok(b),
107        // `:set name=1` / `=0`, and the nvim API's integer booleans.
108        OptionValue::Int(n) => Ok(n != 0),
109        other => Err(format!("option `{name}` expects a boolean, got {other:?}")),
110    }
111}
112
113fn want_int(name: &str, v: OptionValue) -> Result<i64, String> {
114    match v {
115        OptionValue::Int(n) => Ok(n),
116        other => Err(format!("option `{name}` expects a number, got {other:?}")),
117    }
118}
119
120fn want_str(name: &str, v: OptionValue) -> Result<String, String> {
121    match v {
122        OptionValue::String(s) => Ok(s),
123        other => Err(format!("option `{name}` expects a string, got {other:?}")),
124    }
125}
126
127// ── entry constructors ───────────────────────────────────────────────────────
128//
129// Each expands to the `(get, set)` pair for one field shape. Non-capturing
130// closures coerce to `fn` pointers, so the table stays a `const`.
131
132macro_rules! opt_bool {
133    ($name:literal, $aliases:expr, $scope:ident, $field:ident) => {
134        OptionDesc {
135            name: $name,
136            aliases: $aliases,
137            kind: OptKind::Bool,
138            scope: OptScope::$scope,
139            get: |s: &Settings| OptionValue::Bool(s.$field),
140            set: |s: &mut Settings, v: OptionValue| {
141                s.$field = want_bool($name, v)?;
142                Ok(())
143            },
144        }
145    };
146}
147
148macro_rules! opt_int {
149    ($name:literal, $aliases:expr, $scope:ident, $field:ident, $ty:ty, $min:literal, $max:expr) => {
150        OptionDesc {
151            name: $name,
152            aliases: $aliases,
153            kind: OptKind::Int {
154                min: $min,
155                max: $max,
156            },
157            scope: OptScope::$scope,
158            get: |s: &Settings| OptionValue::Int(s.$field as i64),
159            set: |s: &mut Settings, v: OptionValue| {
160                let n = want_int($name, v)?;
161                if n < i64::from($min) || n > i64::from($max) {
162                    return Err(format!(
163                        "{} must be in range {}..={}, got {n}",
164                        $name, $min, $max
165                    ));
166                }
167                s.$field = n as $ty;
168                Ok(())
169            },
170        }
171    };
172}
173
174macro_rules! opt_str {
175    ($name:literal, $aliases:expr, $scope:ident, $field:ident) => {
176        OptionDesc {
177            name: $name,
178            aliases: $aliases,
179            kind: OptKind::Str,
180            scope: OptScope::$scope,
181            get: |s: &Settings| OptionValue::String(s.$field.clone()),
182            set: |s: &mut Settings, v: OptionValue| {
183                s.$field = want_str($name, v)?;
184                Ok(())
185            },
186        }
187    };
188}
189
190/// `min`/`max` are `u32` in [`OptKind::Int`]; `u32::MAX` is the "unbounded"
191/// spelling.
192const UNBOUNDED: u32 = u32::MAX;
193
194const SIGNCOLUMN_VALUES: &[&str] = &["yes", "no", "auto"];
195const FOLDMETHOD_VALUES: &[&str] = &["manual", "expr", "syntax", "marker"];
196const DIAGINLINE_VALUES: &[&str] = &["off", "current", "all"];
197
198/// Every `:set`-able option, in `:set all` display order (grouped by concern).
199pub const OPTIONS: &[OptionDesc] = &[
200    // ── indentation ─────────────────────────────────────────────────────────
201    opt_int!(
202        "shiftwidth",
203        &["sw"],
204        Global,
205        shiftwidth,
206        usize,
207        1,
208        UNBOUNDED
209    ),
210    opt_int!("tabstop", &["ts"], Global, tabstop, usize, 1, UNBOUNDED),
211    opt_int!(
212        "softtabstop",
213        &["sts"],
214        Global,
215        softtabstop,
216        usize,
217        0,
218        UNBOUNDED
219    ),
220    opt_bool!("expandtab", &["et"], Global, expandtab),
221    opt_bool!("autoindent", &["ai"], Global, autoindent),
222    opt_bool!("smartindent", &["si"], Global, smartindent),
223    // ── search ──────────────────────────────────────────────────────────────
224    opt_bool!("ignorecase", &["ic"], Global, ignore_case),
225    opt_bool!("smartcase", &["scs"], Global, smartcase),
226    opt_bool!("wrapscan", &["ws"], Global, wrapscan),
227    opt_bool!("hlsearch", &["hls"], Global, hlsearch),
228    opt_bool!("incsearch", &["is"], Global, incsearch),
229    // ── gutter and cursor highlights ────────────────────────────────────────
230    opt_bool!("number", &["nu"], Global, number),
231    opt_bool!("relativenumber", &["rnu"], Global, relativenumber),
232    opt_int!("numberwidth", &["nuw"], Global, numberwidth, usize, 1, 20),
233    opt_bool!("cursorline", &["cul"], Global, cursorline),
234    opt_bool!("cursorcolumn", &["cuc"], Global, cursorcolumn),
235    OptionDesc {
236        name: "signcolumn",
237        aliases: &["scl"],
238        kind: OptKind::Enum(SIGNCOLUMN_VALUES),
239        scope: OptScope::Global,
240        get: |s| {
241            OptionValue::String(
242                match s.signcolumn {
243                    SignColumnMode::Yes => "yes",
244                    SignColumnMode::No => "no",
245                    SignColumnMode::Auto => "auto",
246                }
247                .to_string(),
248            )
249        },
250        set: |s, v| {
251            s.signcolumn = match want_str("signcolumn", v)?.as_str() {
252                "yes" => SignColumnMode::Yes,
253                "no" => SignColumnMode::No,
254                "auto" => SignColumnMode::Auto,
255                other => {
256                    return Err(format!(
257                        "signcolumn must be `yes`, `no`, or `auto`, got `{other}`"
258                    ));
259                }
260            };
261            Ok(())
262        },
263    },
264    opt_str!("colorcolumn", &["cc"], Global, colorcolumn),
265    // ── wrapping and scrolling ──────────────────────────────────────────────
266    //
267    // `wrap` and `linebreak` are two `:set` names over one tri-state field.
268    // `:set wrap` picks char-break unless `linebreak` already chose word-break;
269    // `:set nolinebreak` drops back to char-break rather than turning wrapping
270    // off. Keeping both here (rather than one "wrap mode" enum) is what makes
271    // `:set nowrap` and `:set invlinebreak` behave the way vim's do.
272    OptionDesc {
273        name: "wrap",
274        aliases: &[],
275        kind: OptKind::Bool,
276        scope: OptScope::Global,
277        get: |s| OptionValue::Bool(s.wrap != hjkl_buffer::Wrap::None),
278        set: |s, v| {
279            s.wrap = if want_bool("wrap", v)? {
280                match s.wrap {
281                    hjkl_buffer::Wrap::Word => hjkl_buffer::Wrap::Word,
282                    _ => hjkl_buffer::Wrap::Char,
283                }
284            } else {
285                hjkl_buffer::Wrap::None
286            };
287            Ok(())
288        },
289    },
290    OptionDesc {
291        name: "linebreak",
292        aliases: &["lbr"],
293        kind: OptKind::Bool,
294        scope: OptScope::Global,
295        get: |s| OptionValue::Bool(s.wrap == hjkl_buffer::Wrap::Word),
296        set: |s, v| {
297            s.wrap = if want_bool("linebreak", v)? {
298                hjkl_buffer::Wrap::Word
299            } else {
300                match s.wrap {
301                    hjkl_buffer::Wrap::None => hjkl_buffer::Wrap::None,
302                    _ => hjkl_buffer::Wrap::Char,
303                }
304            };
305            Ok(())
306        },
307    },
308    opt_int!("textwidth", &["tw"], Global, textwidth, usize, 1, UNBOUNDED),
309    opt_int!("scrolloff", &["so"], Global, scrolloff, usize, 0, UNBOUNDED),
310    opt_int!(
311        "sidescrolloff",
312        &["siso"],
313        Global,
314        sidescrolloff,
315        usize,
316        0,
317        UNBOUNDED
318    ),
319    opt_int!(
320        "scroll_duration_ms",
321        &[],
322        Global,
323        scroll_duration_ms,
324        u16,
325        0,
326        65535
327    ),
328    // ── folding ─────────────────────────────────────────────────────────────
329    OptionDesc {
330        name: "foldmethod",
331        aliases: &["fdm"],
332        kind: OptKind::Enum(FOLDMETHOD_VALUES),
333        scope: OptScope::Global,
334        get: |s| {
335            OptionValue::String(
336                match s.foldmethod {
337                    FoldMethod::Manual => "manual",
338                    FoldMethod::Expr => "expr",
339                    FoldMethod::Marker => "marker",
340                }
341                .to_string(),
342            )
343        },
344        set: |s, v| {
345            s.foldmethod = match want_str("foldmethod", v)?.as_str() {
346                "manual" => FoldMethod::Manual,
347                // vim spells tree-sitter-driven folding `syntax`; hjkl calls
348                // the same thing `expr`. Accepted, normalised on read-back.
349                "expr" | "syntax" => FoldMethod::Expr,
350                "marker" => FoldMethod::Marker,
351                other => {
352                    return Err(format!(
353                        "foldmethod must be `manual`, `expr`, `syntax`, or `marker`, got `{other}`"
354                    ));
355                }
356            };
357            Ok(())
358        },
359    },
360    opt_bool!("foldenable", &["fen"], Global, foldenable),
361    opt_int!("foldcolumn", &["fdc"], Global, foldcolumn, u32, 0, 12),
362    opt_int!(
363        "foldlevelstart",
364        &["fls"],
365        Global,
366        foldlevelstart,
367        u32,
368        0,
369        UNBOUNDED
370    ),
371    opt_str!("foldmarker", &["fmr"], Global, foldmarker),
372    // ── invisibles and guides ───────────────────────────────────────────────
373    opt_bool!("list", &[], Global, list),
374    OptionDesc {
375        name: "listchars",
376        aliases: &["lcs"],
377        kind: OptKind::Str,
378        scope: OptScope::Global,
379        get: |s| OptionValue::String(s.listchars.to_canonical_string()),
380        set: |s, v| {
381            s.listchars = hjkl_buffer::ListChars::parse(&want_str("listchars", v)?)?;
382            Ok(())
383        },
384    },
385    opt_bool!("indent_guides", &["ig"], Global, indent_guides),
386    OptionDesc {
387        name: "indent_guide_char",
388        aliases: &["igc"],
389        kind: OptKind::Str,
390        scope: OptScope::Global,
391        get: |s| OptionValue::String(s.indent_guide_char.to_string()),
392        set: |s, v| {
393            let text = want_str("indent_guide_char", v)?;
394            let mut chars = text.chars();
395            let (Some(ch), None) = (chars.next(), chars.next()) else {
396                return Err(format!(
397                    "indent_guide_char expects exactly one character, got {text:?}"
398                ));
399            };
400            s.indent_guide_char = ch;
401            Ok(())
402        },
403    },
404    // ── editing behaviour ───────────────────────────────────────────────────
405    opt_str!("iskeyword", &["isk"], Global, iskeyword),
406    opt_str!("formatoptions", &["fo"], Global, formatoptions),
407    opt_bool!("autopair", &["ap"], Global, autopair),
408    opt_bool!("autoclose-tag", &["act"], Global, autoclose_tag),
409    opt_bool!("motion_sneak", &["snk"], Global, motion_sneak),
410    opt_bool!("matchparen", &["mps"], Global, matchparen),
411    opt_int!(
412        "undolevels",
413        &["ul"],
414        Global,
415        undo_levels,
416        u32,
417        0,
418        UNBOUNDED
419    ),
420    opt_bool!("undobreak", &[], Global, undo_break_on_motion),
421    OptionDesc {
422        name: "timeoutlen",
423        aliases: &["tm"],
424        kind: OptKind::Int {
425            min: 0,
426            max: UNBOUNDED,
427        },
428        scope: OptScope::Global,
429        get: |s| OptionValue::Int(s.timeout_len.as_millis() as i64),
430        set: |s, v| {
431            let n = want_int("timeoutlen", v)?;
432            if n < 0 {
433                return Err(format!("timeoutlen must not be negative, got {n}"));
434            }
435            s.timeout_len = core::time::Duration::from_millis(n as u64);
436            Ok(())
437        },
438    },
439    opt_int!("updatetime", &["ut"], Global, updatetime, u32, 0, UNBOUNDED),
440    // ── save behaviour ──────────────────────────────────────────────────────
441    opt_bool!("format_on_save", &["fos"], Global, format_on_save),
442    opt_bool!(
443        "trim_trailing_whitespace",
444        &["tts"],
445        Global,
446        trim_trailing_whitespace
447    ),
448    opt_bool!("fixendofline", &["fixeol"], Global, fixendofline),
449    opt_bool!("autoreload", &["ar"], Global, autoreload),
450    // ── external tooling ────────────────────────────────────────────────────
451    opt_str!("makeprg", &["mp"], Global, makeprg),
452    opt_str!("errorformat", &["efm"], Global, errorformat),
453    // ── visual extras ───────────────────────────────────────────────────────
454    opt_bool!("rainbow_brackets", &["rb"], Global, rainbow_brackets),
455    opt_bool!("colorizer", &[], Global, colorizer),
456    OptionDesc {
457        name: "colorizer_filetypes",
458        aliases: &[],
459        kind: OptKind::StrList,
460        scope: OptScope::Global,
461        get: |s| OptionValue::String(s.colorizer_filetypes.join(",")),
462        set: |s, v| {
463            // Empty entries are dropped so a trailing comma is harmless rather
464            // than creating a filetype named "" that matches nothing.
465            s.colorizer_filetypes = want_str("colorizer_filetypes", v)?
466                .split(',')
467                .map(str::trim)
468                .filter(|p| !p.is_empty())
469                .map(str::to_string)
470                .collect();
471            Ok(())
472        },
473    },
474    opt_bool!("tabline_icons", &[], Global, tabline_icons),
475    opt_bool!("blame_inline", &[], Global, blame_inline),
476    OptionDesc {
477        name: "diagnostics_inline",
478        aliases: &["diaginline"],
479        kind: OptKind::Enum(DIAGINLINE_VALUES),
480        scope: OptScope::Global,
481        get: |s| {
482            OptionValue::String(
483                match s.diagnostics_inline {
484                    DiagInlineMode::Off => "off",
485                    DiagInlineMode::Current => "current",
486                    DiagInlineMode::All => "all",
487                }
488                .to_string(),
489            )
490        },
491        set: |s, v| {
492            s.diagnostics_inline = match want_str("diagnostics_inline", v)?.as_str() {
493                "off" | "no" | "disable" | "disabled" => DiagInlineMode::Off,
494                "current" | "cursor" | "line" => DiagInlineMode::Current,
495                "all" | "on" | "enable" | "enabled" => DiagInlineMode::All,
496                other => {
497                    return Err(format!(
498                        "diagnostics_inline must be `off`, `current`, or `all`, got `{other}`"
499                    ));
500                }
501            };
502            Ok(())
503        },
504    },
505    // ── modelines ───────────────────────────────────────────────────────────
506    opt_bool!("modeline", &["ml"], Global, modeline),
507    opt_int!("modelines", &["mls"], Global, modelines, u32, 0, UNBOUNDED),
508    // ── buffer-local (never persisted) ──────────────────────────────────────
509    opt_bool!("readonly", &["ro"], BufferLocal, readonly),
510    opt_bool!("modifiable", &["ma"], BufferLocal, modifiable),
511    opt_str!("filetype", &["ft"], BufferLocal, filetype),
512    opt_str!("commentstring", &["cms"], BufferLocal, commentstring),
513];
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518
519    #[test]
520    fn names_and_aliases_are_unique() {
521        let mut seen = std::collections::HashSet::new();
522        for d in OPTIONS {
523            for n in d.all_names() {
524                assert!(seen.insert(n), "`{n}` is registered twice");
525            }
526        }
527    }
528
529    /// Every entry must round-trip its own default: `set(get(x))` is a no-op.
530    /// Catches a getter and setter that disagree about representation — the
531    /// enum spellings and the `wrap` / `linebreak` pair especially.
532    #[test]
533    fn get_set_round_trips_for_every_option() {
534        for d in OPTIONS {
535            let mut s = Settings::default();
536            let before = (d.get)(&s);
537            (d.set)(&mut s, before.clone())
538                .unwrap_or_else(|e| panic!("`{}` rejected its own value: {e}", d.name));
539            let after = (d.get)(&s);
540            assert_eq!(before, after, "`{}` did not round-trip", d.name);
541        }
542    }
543
544    /// A boolean must actually flip, or the `no…` / `inv…` / `!` forms built
545    /// on top of `OptKind::Bool` would silently do nothing.
546    #[test]
547    fn every_bool_option_flips() {
548        for d in OPTIONS.iter().filter(|d| d.kind == OptKind::Bool) {
549            let mut s = Settings::default();
550            let OptionValue::Bool(before) = (d.get)(&s) else {
551                panic!("`{}` is OptKind::Bool but does not get a Bool", d.name);
552            };
553            (d.set)(&mut s, OptionValue::Bool(!before)).unwrap();
554            let OptionValue::Bool(after) = (d.get)(&s) else {
555                unreachable!()
556            };
557            assert_eq!(after, !before, "`{}` did not flip", d.name);
558        }
559    }
560
561    /// Enum options must accept every value they advertise for completion.
562    #[test]
563    fn every_enum_value_is_accepted() {
564        for d in OPTIONS {
565            let OptKind::Enum(values) = d.kind else {
566                continue;
567            };
568            for v in values {
569                let mut s = Settings::default();
570                (d.set)(&mut s, OptionValue::String((*v).to_string())).unwrap_or_else(|e| {
571                    panic!(
572                        "`{}` advertises `{v}` for completion but rejects it: {e}",
573                        d.name
574                    )
575                });
576            }
577        }
578    }
579
580    /// Integer bounds must admit their own endpoints, and reject just past
581    /// them.
582    #[test]
583    fn int_bounds_admit_endpoints_and_reject_beyond() {
584        for d in OPTIONS {
585            let OptKind::Int { min, max } = d.kind else {
586                continue;
587            };
588            let mut s = Settings::default();
589            (d.set)(&mut s, OptionValue::Int(i64::from(min)))
590                .unwrap_or_else(|e| panic!("`{}` rejected its own min {min}: {e}", d.name));
591            if min > 0 {
592                assert!(
593                    (d.set)(&mut s, OptionValue::Int(i64::from(min) - 1)).is_err(),
594                    "`{}` accepted a value below min {min}",
595                    d.name
596                );
597            }
598            if max != UNBOUNDED {
599                (d.set)(&mut s, OptionValue::Int(i64::from(max)))
600                    .unwrap_or_else(|e| panic!("`{}` rejected its own max {max}: {e}", d.name));
601                assert!(
602                    (d.set)(&mut s, OptionValue::Int(i64::from(max) + 1)).is_err(),
603                    "`{}` accepted a value above max {max}",
604                    d.name
605                );
606            }
607        }
608    }
609
610    #[test]
611    fn lookup_finds_by_alias() {
612        assert_eq!(lookup("cul").unwrap().name, "cursorline");
613        assert_eq!(lookup("ts").unwrap().name, "tabstop");
614        assert!(lookup("definitely-not-an-option").is_none());
615    }
616
617    /// The buffer-local set is a deliberate, short list — anything landing in
618    /// it by accident would stop being persistable without anyone noticing.
619    #[test]
620    fn buffer_local_set_is_exactly_the_documented_four() {
621        let mut got: Vec<&str> = OPTIONS
622            .iter()
623            .filter(|d| d.scope == OptScope::BufferLocal)
624            .map(|d| d.name)
625            .collect();
626        got.sort_unstable();
627        assert_eq!(
628            got,
629            ["commentstring", "filetype", "modifiable", "readonly"],
630            "the buffer-local set changed — is the new option really per-buffer?"
631        );
632    }
633}