1use crate::editor::Settings;
40use crate::types::{DiagInlineMode, FoldMethod, OptionValue, SignColumnMode};
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum OptKind {
45 Bool,
48 Int { min: u32, max: u32 },
50 Str,
52 Enum(&'static [&'static str]),
54 StrList,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum OptScope {
67 Global,
68 BufferLocal,
69}
70
71pub struct OptionDesc {
73 pub name: &'static str,
75 pub aliases: &'static [&'static str],
77 pub kind: OptKind,
78 pub scope: OptScope,
79 pub get: fn(&Settings) -> OptionValue,
81 pub set: fn(&mut Settings, OptionValue) -> Result<(), String>,
83}
84
85impl OptionDesc {
86 pub fn matches(&self, name: &str) -> bool {
88 self.name == name || self.aliases.contains(&name)
89 }
90
91 pub fn all_names(&self) -> impl Iterator<Item = &'static str> {
93 std::iter::once(self.name).chain(self.aliases.iter().copied())
94 }
95}
96
97pub fn lookup(name: &str) -> Option<&'static OptionDesc> {
99 OPTIONS.iter().find(|d| d.matches(name))
100}
101
102fn want_bool(name: &str, v: OptionValue) -> Result<bool, String> {
105 match v {
106 OptionValue::Bool(b) => Ok(b),
107 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
127macro_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
190const 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
198pub const OPTIONS: &[OptionDesc] = &[
200 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 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 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 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 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 "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 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 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 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 opt_str!("makeprg", &["mp"], Global, makeprg),
452 opt_str!("errorformat", &["efm"], Global, errorformat),
453 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 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 opt_bool!("modeline", &["ml"], Global, modeline),
507 opt_int!("modelines", &["mls"], Global, modelines, u32, 0, UNBOUNDED),
508 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 #[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 #[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 #[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 #[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 #[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}