Skip to main content

lattice_config/
core_options.rs

1// `linkme`'s distributed slices use `link_section` to aggregate
2// items at link time. The macro expansions in this file emit
3// such declarations; allow the workspace's `unsafe_code = "deny"`
4// lint locally with the same safety rationale documented in
5// `option_decl.rs` and `group.rs`.
6#![allow(unsafe_code)]
7
8//! Renderer-agnostic options. M.2.0b migrates these from the
9//! pre-typed-keys imperative `Option::builder()` form to the
10//! macro-driven declarative form (Design B + D from the
11//! `mode-architecture.md` discussion).
12//!
13//! Each option is a unique Rust type emitted by [`crate::options!`].
14//! The macro generates the [`crate::OptionDecl`] / [`crate::HasGroup`]
15//! impls, a `build_spec()` helper that constructs the runtime
16//! `Option<T>` via the existing builder, and a `linkme`
17//! self-registration thunk submitted to [`crate::OPTION_DECLS`].
18//! At App boot the registry's [`crate::ConfigRegistry::init_from_linkme`]
19//! walks the slice and registers every option without a central
20//! `register_core_options` body.
21//!
22//! For backwards compatibility during the transitional M.2.0b/c
23//! window, `register_core_options` runs `init_from_linkme` and
24//! returns a `CoreOptions` struct populated with the typed
25//! handles. Existing callers (`config.get(core.tabstop)`)
26//! continue to work unchanged. M.2.0c migrates the callers to
27//! `config.get_typed::<Tabstop>()` and retires `CoreOptions`.
28
29use crate::expand_height::ExpandHeight;
30use crate::signcolumn::SignColumn;
31use lattice_core::FoldMethod;
32use lattice_core::IndentMethod;
33use lattice_core::{AutoWrap, FormatProvider, ProviderChain};
34
35// Validators referenced by `#[validate(...)]` on the options
36// below. Plain Rust functions; the macro just records the path.
37// `&String` is required by the typed-option machinery's
38// validator signature; clippy's `ptr_arg` lint flags this as
39// "use `&str` instead", but doing so breaks the macro-emitted
40// caller.
41#[allow(clippy::ptr_arg)]
42fn validate_log_level(s: &String) -> Result<(), String> {
43    match s.as_str() {
44        "error" | "warn" | "info" | "debug" | "trace" => Ok(()),
45        other => Err(format!(
46            "lsp.log_level must be one of `error`/`warn`/`info`/`debug`/`trace`, got `{other}`"
47        )),
48    }
49}
50
51/// AI-1b: dedicated validator for `ai.log_level`, mirroring
52/// `validate_log_level` above but naming `ai.log_level` (not
53/// `lsp.log_level`) in the error so `:set ai.log_level=bogus`
54/// points at the right option.
55#[allow(clippy::ptr_arg)]
56fn validate_ai_log_level(s: &String) -> Result<(), String> {
57    match s.as_str() {
58        "error" | "warn" | "info" | "debug" | "trace" => Ok(()),
59        other => Err(format!(
60            "ai.log_level must be one of `error`/`warn`/`info`/`debug`/`trace`, got `{other}`"
61        )),
62    }
63}
64
65fn validate_log_capacity(i: &i64) -> Result<(), String> {
66    if *i < 0 {
67        Err(format!("lsp.log_capacity must be >= 0, got {i}"))
68    } else {
69        Ok(())
70    }
71}
72
73/// Validate a `tracing-subscriber::EnvFilter` directive. Accepts
74/// the standard syntax: a level name (`info`), per-target
75/// directives (`lsp=debug`), or a comma-separated list
76/// (`editor=info,lsp=debug,grammar=trace`).
77#[allow(clippy::ptr_arg)]
78fn validate_messages_filter(s: &String) -> Result<(), String> {
79    // The parse itself is the validation -- tracing-subscriber
80    // returns a typed error on bad syntax (unknown level,
81    // unparseable target, etc.).
82    tracing_subscriber::EnvFilter::try_new(s.as_str())
83        .map(|_| ())
84        .map_err(|e| format!("messages.filter: {e}"))
85}
86
87fn validate_tabstop(i: &i64) -> Result<(), String> {
88    if (1..=32).contains(i) {
89        Ok(())
90    } else {
91        // Migration constraint: error wording matches the
92        // pre-typed-options setter (test
93        // `tabstop_validate_rejects_out_of_range_with_legacy_message`).
94        Err(format!("tabstop out of range [1, 32]: {i}"))
95    }
96}
97
98/// IN.0: `shiftwidth` shares `tabstop`'s range but not its meaning —
99/// this is columns per *indent level*, not the display width of a tab
100/// byte. Separate validator so `:set shiftwidth=99` names the option
101/// the user actually typed.
102fn validate_shiftwidth(i: &i64) -> Result<(), String> {
103    if (1..=32).contains(i) {
104        Ok(())
105    } else {
106        Err(format!("shiftwidth out of range [1, 32]: {i}"))
107    }
108}
109
110/// RF.0: `textwidth` must be a usable column.
111///
112/// **Zero is not accepted, unlike vim's**, and that is the one place
113/// this option deliberately diverges. In vim `textwidth=0` means both
114/// "never auto-wrap" and "`gq` has no target" — one value carrying two
115/// decisions, which is why `gq` then falls back to a hidden 79. Here
116/// `autowrap=off` says the first and `textwidth` only ever means the
117/// second, so it always has to be a real column.
118///
119/// The ceiling is generous rather than principled; past a few hundred
120/// columns nothing is being read by a human.
121fn validate_textwidth(i: &i64) -> Result<(), String> {
122    if (1..=10_000).contains(i) {
123        Ok(())
124    } else {
125        Err(format!(
126            "textwidth out of range [1, 10000]: {i} \
127             (to stop wrapping while you type, set `autowrap=off` — \
128             textwidth stays the target `gq` reflows to)"
129        ))
130    }
131}
132
133fn validate_yank_ring_size(i: &i64) -> Result<(), String> {
134    // 0 disables. The ceiling is generous — the ring is held whole in
135    // memory and rendered by a fuzzy picker, and past a few thousand
136    // entries neither of those is the tool you want.
137    if (0..=10_000).contains(i) {
138        Ok(())
139    } else {
140        Err(format!("yank.ring.size out of range [0, 10000]: {i}"))
141    }
142}
143
144fn validate_foldlevel(i: &i64) -> Result<(), String> {
145    // Negative is meaningless (level 1 is the outermost fold, so 0
146    // already closes everything). The ceiling is far past any real
147    // nesting depth and exists so a typo'd `:set foldlevel=999999`
148    // reports an error instead of silently meaning "open".
149    if (0..=1024).contains(i) {
150        Ok(())
151    } else {
152        Err(format!("foldlevel out of range [0, 1024]: {i}"))
153    }
154}
155
156fn validate_scrolloff(i: &i64) -> Result<(), String> {
157    if (0..=64).contains(i) {
158        Ok(())
159    } else {
160        Err(format!("scrolloff out of range [0, 64]: {i}"))
161    }
162}
163
164fn validate_sidescroll(i: &i64) -> Result<(), String> {
165    // 0 = jump-scroll (cursor to the middle of the window), matching
166    // vim's default. Positive values step that many columns at a
167    // time. Upper bound mirrors vim's practical ceiling.
168    if (0..=1024).contains(i) {
169        Ok(())
170    } else {
171        Err(format!("sidescroll out of range [0, 1024]: {i}"))
172    }
173}
174
175fn validate_sidescrolloff(i: &i64) -> Result<(), String> {
176    if (0..=1024).contains(i) {
177        Ok(())
178    } else {
179        Err(format!("sidescrolloff out of range [0, 1024]: {i}"))
180    }
181}
182
183fn validate_modeline_padding(i: &i64) -> Result<(), String> {
184    if (0..=16).contains(i) {
185        Ok(())
186    } else {
187        Err(format!("ui.modeline.padding out of range [0, 16]: {i}"))
188    }
189}
190
191fn validate_terminal_scrollback_lines(i: &i64) -> Result<(), String> {
192    if *i < 0 {
193        Err(format!(
194            "terminal.scrollback-lines must be >= 0 (use 0 to disable scrollback), got {i}"
195        ))
196    } else if *i > 1_000_000 {
197        Err(format!(
198            "terminal.scrollback-lines capped at 1_000_000 (≈ 80 MB of cells); got {i}"
199        ))
200    } else {
201        Ok(())
202    }
203}
204
205fn validate_completion_priority(i: &i64) -> Result<(), String> {
206    if (0..=9999).contains(i) {
207        Ok(())
208    } else {
209        Err(format!("priority out of range [0, 9999]: {i}"))
210    }
211}
212
213#[allow(clippy::ptr_arg)]
214fn validate_picker_display(s: &String) -> Result<(), String> {
215    match s.as_str() {
216        "popup" | "minibuffer" => Ok(()),
217        other => Err(format!(
218            "picker.display must be one of `popup`/`minibuffer`, got `{other}`"
219        )),
220    }
221}
222
223// ---- Editor group: bare-named editor options ----
224//
225// Reserved namespace per `mode-architecture.md` §6.5.2 / §6.8.
226// Bare names (no prefix) are the user's first-class options;
227// plugins must use their own `<plugin-id>.` prefix.
228
229crate::options! {
230    group = crate::Editor;
231
232    /// Show absolute line numbers in the gutter.
233    #[aliases("nu")]
234    #[name("number")]
235    pub Number: bool = true;
236
237    /// Gutter shows distance from the cursor; the cursor's line
238    /// shows its absolute number.
239    #[aliases("rnu")]
240    #[name("relativenumber")]
241    pub RelativeNumber: bool = false;
242
243    /// Wrap long lines visually instead of horizontal scrolling.
244    pub Wrap: bool = false;
245
246    /// Whether the renderer reserves the gutter sign columns
247    /// (diagnostics severity + diff sign). `yes` (default) always
248    /// reserves them so layout never shifts when a sign appears;
249    /// `no` hides them. Help / synthetic buffers set `no` for clean
250    /// gutterless rendering — the renderer derives the column layout
251    /// from this option alone, never from buffer kind / popup / pane.
252    #[aliases("scl")]
253    #[name("signcolumn")]
254    pub SignColumnOption: SignColumn = SignColumn::Yes;
255
256    /// MB.2e: how tall the `:` command line grows when expanded into
257    /// its full-modal mini-buffer band (`<C-x><C-e>`). `half` (default)
258    /// claims half the frame; `full` grows as tall as the frame allows
259    /// (one pane row kept); a bare integer pins a fixed row count. Pure
260    /// render policy — both peers resolve it against the live frame
261    /// height via `ExpandHeight::rows`.
262    #[name("command-line.expand-height")]
263    pub CommandLineExpandHeight: ExpandHeight = ExpandHeight::Half;
264
265    /// When `true` (default), an explicit **yank** (`y`, `yy`, Visual
266    /// `y`) also copies to the system clipboard, and paste of the
267    /// unnamed register reads it — the clipboard is the default yank
268    /// target. Delete / change / `x` stay in registers and never touch
269    /// the clipboard (the *yank-only* rule — no incidental clobber).
270    /// `false` = pure registers; only the explicit `"+` / `"*`
271    /// registers reach the clipboard.
272    ///
273    /// A plain boolean by deliberate design (`clipboard.md` §5):
274    /// lattice rejects vim's crude `unnamed` / `unnamedplus` string
275    /// names in favour of a self-documenting on/off.
276    #[name("clipboard")]
277    pub ClipboardEnabled: bool = true;
278
279    /// Show buffer-relative paths (modeline, LSP references, etc.)
280    /// instead of absolute paths. Requires `:cd` to set the base
281    /// directory; falls back to absolute when no base is set.
282    #[name("path.relative")]
283    pub PathRelative: bool = false;
284
285    /// Ignore case in search patterns.
286    #[aliases("ic")]
287    #[name("ignorecase")]
288    pub IgnoreCase: bool = false;
289
290    /// Number of columns a hard tab character renders as. The
291    /// cells builder expands each `\t` to the next multiple of this
292    /// width (W.4.t). Default 4 (Lattice's house style; vim's
293    /// historical default is 8).
294    #[aliases("ts")]
295    #[validate(validate_tabstop)]
296    pub Tabstop: i64 = 4;
297
298    // ---- Indentation (IN.0; docs/dev/architecture/auto-indent.md §3) ----
299    //
300    // This block declares the WHOLE indent option surface at once, even
301    // though later slices light up what honours it. Splitting the
302    // declaration across four slices would be four chances for the
303    // names, defaults and validators to drift, and `:describe-option`
304    // metadata written four times. The honoured set grows; the declared
305    // set lands once.
306
307    /// Columns added or removed per indent level -- by `>` / `<`,
308    /// `<C-t>` / `<C-d>`, `=`, and auto-indent. Distinct from
309    /// `tabstop`, which is the display width of a literal tab byte:
310    /// conflating them makes "change my indent size" silently reflow
311    /// every file containing a hard tab.
312    #[aliases("sw")]
313    #[validate(validate_shiftwidth)]
314    pub Shiftwidth: i64 = 4;
315
316    /// Render indentation as spaces rather than tab bytes.
317    #[aliases("et")]
318    #[name("expandtab")]
319    pub ExpandTab: bool = true;
320
321    /// Where a newly created line's indent comes from: `none`
322    /// (column 0), `keep` (copy the previous line -- vim's
323    /// `autoindent`), or `syntax` (tree-sitter `indents.scm`,
324    /// falling back to `keep`). A cascade with a named floor, so a
325    /// language with no query degrades to documented vim behaviour
326    /// rather than to a silent wrong answer.
327    ///
328    /// Honoured progressively: `none` from IN.0, `keep` from IN.1,
329    /// `syntax` from IN.2. Until then `syntax` degrades to the
330    /// highest rung that exists -- the cascade behaving as designed.
331    #[aliases("im")]
332    #[name("indentmethod")]
333    pub IndentMethodOption: IndentMethod = IndentMethod::Syntax;
334
335    /// Re-indent the current line when a closing token is typed
336    /// (`}`, `)`, `end`, `else`). Honoured from IN.6.
337    #[aliases("ei")]
338    #[name("electricindent")]
339    pub ElectricIndent: bool = true;
340
341    /// External formatter for `:format` (vim's `formatprg`). Empty
342    /// (the default) falls back to the built-in per-language table.
343    /// Honoured from IN.9.
344    ///
345    /// **Deprecated at RF.0, retired at RF.5** into `format.reformat`:
346    /// `:set format.reformat=external:prettier --stdin-filepath %`.
347    /// Kept working for one release with a note on `:messages` —
348    /// dropping a config key without saying so is indistinguishable
349    /// from a bug.
350    #[name("formatprg")]
351    pub FormatPrg: String = String::new();
352
353    // ---- RF.0: reflow + the formatter provider chains ----
354    //
355    // Declared as one block for the same reason the indent surface was
356    // (IN.0): splitting five option declarations across five slices is
357    // five chances for names, defaults, validators and
358    // `:describe-option` metadata to drift. The HONOURED set grows
359    // slice by slice -- `textwidth` at RF.2, `autowrap` at RF.3, the
360    // chains at RF.5 -- while the DECLARED set lands once.
361    //
362    // See `docs/dev/architecture/text-reflow.md` §6 and §8.
363
364    /// Target column for reflow (`gq` / `gw`) and for wrapping while
365    /// typing. One measure for both, so the two can never disagree
366    /// about where the margin is.
367    ///
368    /// Unrelated to `wrap`, which is *soft* wrap -- a display decision
369    /// that changes no bytes. The two compose: a buffer may soft-wrap
370    /// at the window edge while hard-wrapping here. Honoured from RF.2.
371    #[aliases("tw")]
372    // `textwidth`, not the derived `text-width`: its neighbours in this
373    // block are `shiftwidth`, `expandtab` and `foldmethod`,
374    // and a lone hyphen among them would be the odd one out for no gain.
375    #[name("textwidth")]
376    #[validate(validate_textwidth)]
377    pub TextWidth: i64 = 80;
378
379    /// Whether typing past `textwidth` breaks the line: `off`,
380    /// `comments` (the default -- a long comment wraps, a long string
381    /// literal does not), or `all`.
382    ///
383    /// Prose majors (markdown, org, text, gitcommit) override to `all`
384    /// through `Mode::options()`. Honoured from RF.3.
385    #[aliases("aw")]
386    #[name("autowrap")]
387    pub AutoWrapOption: AutoWrap = AutoWrap::Comments;
388
389    /// Who reindents a range for `=` -- an ordered chain, first
390    /// available rung wins. Defaults to the tree-sitter engine.
391    ///
392    /// `lsp` is *expressible* here and is not the default, deliberately:
393    /// the protocol has no indent-only request, so an `lsp` rung makes
394    /// `=` a reformatter that moves line breaks. That is a legitimate
395    /// thing to want and a bad default. Honoured from RF.5.
396    #[name("format.indent")]
397    pub FormatIndentChain: ProviderChain = ProviderChain::of(FormatProvider::Native);
398
399    /// Who reflows a range for `gq` / `gw`. Defaults to the built-in
400    /// `textwidth` engine.
401    ///
402    /// `lsp` is expressible and not the default for a sharper reason
403    /// than above: the protocol has no reflow request at all, and the
404    /// reformatters behind it mostly leave prose alone (rustfmt's
405    /// `wrap_comments` is off by default; prettier's `proseWrap`
406    /// defaults to `preserve`). An `lsp` rung here silently does
407    /// nothing in the common case. Honoured from RF.5.
408    #[name("format.reflow")]
409    pub FormatReflowChain: ProviderChain = ProviderChain::of(FormatProvider::Native);
410
411    /// Who reformats a range for `:format`, `g=` and format-on-save.
412    /// Defaults to the attached language server, then the built-in
413    /// per-language formatter table -- the exact cascade `:format`
414    /// carried in Rust before RF.5, now as data.
415    ///
416    /// `native` is accepted as a tail rung and means the reflow engine,
417    /// which is a useful last resort for prose: `format.reformat` of
418    /// `external:prettier,native` formats markdown with prettier when it
419    /// is installed and reflows it when it is not. Honoured from RF.5.
420    #[name("format.reformat")]
421    pub FormatReformatChain: ProviderChain =
422        ProviderChain::new(vec![FormatProvider::Lsp, FormatProvider::LangDefault]);
423
424    /// Run the `:format` cascade before `:w`. A formatter that
425    /// fails, exits non-zero, or times out never blocks the write --
426    /// the buffer is saved unformatted. Honoured from IN.9.
427    #[name("formatonsave")]
428    pub FormatOnSave: bool = false;
429
430    /// Whether the buffer is read-only (mutating operators
431    /// reject; `:w` still permits explicit writes if a path
432    /// exists). `customizable = false` because this is
433    /// mode-driven, not a user-typed config: major modes like
434    /// `help-mode`, `file-tree-mode`, and the LSP log modes
435    /// contribute `ReadOnly = true` via `Mode::options()` (per
436    /// `mode-architecture.md` §6.5.3 read-only-mode pattern).
437    /// Users who want to flip it on / off for a particular
438    /// buffer use `:enable read-only-mode`, not
439    /// `:set read-only=true`. That minor **exists** — it is
440    /// declared in `lattice_mode::modes::display` and registered
441    /// with the other foundation modes; the parenthetical here
442    /// used to read "when that minor mode lands in M.7" long
443    /// after it had, which is enough to send a reader off to
444    /// build a second one.
445    ///
446    /// A provider whose buffer is read-only *conditionally* —
447    /// same kind, same major, editable or not depending on what
448    /// it is showing — activates that minor on the buffer via
449    /// `ModeActivator::activate_minor_by_id` and clears it with
450    /// `deactivate_minor_by_id`, rather than reaching for this
451    /// option directly.
452    #[customizable(false)]
453    #[name("read-only")]
454    pub ReadOnly: bool = false;
455
456    /// Whether the buffer is NOT backed by an on-disk file the
457    /// editor can save and should track for unsaved changes.
458    /// `false` (the default) means `:q` warns on dirty, `:w`
459    /// writes to disk, and the modeline shows `[+]` for modified
460    /// state. `true` (vim's `&buftype = nofile`) means the buffer
461    /// is a transcript / log / overlay whose content is owned by a
462    /// subsystem; the dirty guard skips it and `:w` is a no-op.
463    /// `customizable = false` — modes contribute
464    /// the override (`messages-mode`, `lsp-log-mode`, `help-mode`,
465    /// `terminal-mode` set `NoFile = true`); users don't `:set`
466    /// it directly.
467    #[customizable(false)]
468    #[name("no-file")]
469    pub NoFile: bool = false;
470
471    /// When `true` (the default), a file-backed buffer refreshes when its
472    /// on-disk content changes out from under the editor (vim's
473    /// `autoread`): an unmodified buffer reloads silently; a buffer with
474    /// unsaved edits opens a diff resolver rather than clobbering either
475    /// side. `false` disables external-change watching for the buffer.
476    /// Non-file buffers (oil, help, synthetic) are never watched
477    /// regardless. See `docs/dev/architecture/autoread.md`.
478    #[name("autoread")]
479    pub Autoread: bool = true;
480
481    /// When false (`:set nofoldenable`, `zi`), every fold renders
482    /// as open regardless of its closed flag. Closed-state is
483    /// preserved -- toggling back restores the previous
484    /// distribution.
485    #[aliases("fen")]
486    #[name("foldenable")]
487    pub FoldEnable: bool = true;
488
489    /// How folds are produced: `manual` (zf only), `indent` (auto
490    /// from indentation), `markdown` (ATX heading nesting), or
491    /// `syntax` (tree-sitter cascade -- markdown for `.md`,
492    /// indent otherwise).
493    #[aliases("fdm")]
494    #[name("foldmethod")]
495    pub FoldMethodOption: FoldMethod = FoldMethod::Manual;
496
497    /// Folds nested deeper than this level are closed; the rest are
498    /// open. The outermost fold is level 1, so `foldlevel=0` closes
499    /// everything and `foldlevel=1` shows only the top level's
500    /// structure. In a multibuffer view that means `0` gives one row
501    /// per file and `1` gives one row per excerpt.
502    ///
503    /// Setting it is a bulk action, applied at the moment of the
504    /// `:set`. Afterwards `za` / `zo` / `zc` adjust individual folds
505    /// without changing the option, and a fold whose state the user has
506    /// touched keeps it across rebuilds -- `foldlevel` only decides the
507    /// initial state of folds that appear later.
508    ///
509    /// **Default deviates from vim deliberately.** Vim defaults this to
510    /// `0`, which is why practically every vimrc carries
511    /// `set foldlevelstart=99` -- the default means "open every file
512    /// fully collapsed", which few people want. Lattice would suffer
513    /// that worse than vim does: `foldmethod` defaults to `manual`, so
514    /// an ordinary document has no folds, but overlay fold sources are
515    /// always registered (multibuffer file/excerpt folds, diff hunk
516    /// folds, the AI conversation's tool-call folds). A `0` default
517    /// would open every search result, project diff and agent transcript
518    /// collapsed to nothing. `99` is the effective-infinity value the
519    /// vim idiom settled on, and it keeps the shipped behaviour.
520    #[aliases("fdl")]
521    #[name("foldlevel")]
522    #[validate(validate_foldlevel)]
523    pub FoldLevel: i64 = 99;
524
525    /// How many entries the yank ring holds. Every yank *and* every
526    /// delete pushes one.
527    ///
528    /// Read it back with `<C-r><C-r>` in Insert mode, or
529    /// `:picker yank-ring` from anywhere.
530    ///
531    /// Vim keeps 9, emacs 120. 50 is enough that the picker's fuzzy
532    /// filter is the tool you reach for rather than scrolling, and small
533    /// enough that the whole ring stays cheap to hold and to render. `0`
534    /// disables the ring.
535    ///
536    /// Read at push time, so lowering it takes effect on the next yank
537    /// rather than at the next restart.
538    #[name("yank.ring.size")]
539    #[validate(validate_yank_ring_size)]
540    pub YankRingSize: i64 = 50;
541
542    /// Minimum visual lines kept above and below the cursor when
543    /// scrolling.
544    #[aliases("so")]
545    #[validate(validate_scrolloff)]
546    pub Scrolloff: i64 = 0;
547
548    /// Lines `<C-d>` / `<C-u>` scroll. `0` (vim's default) means half the
549    /// window, recomputed as the window resizes.
550    #[aliases("scr")]
551    pub Scroll: i64 = 0;
552
553    /// Whether `H` / `M` / `L` land on the first non-blank of their line
554    /// (vim's default) or keep the cursor's column (`nostartofline`).
555    #[aliases("sol")]
556    #[name("startofline")]
557    pub StartOfLine: bool = true;
558
559    /// Columns to scroll horizontally when the cursor moves off the
560    /// edge with `wrap` off. `0` (vim default) jumps so the cursor
561    /// lands in the middle of the window; a positive value scrolls
562    /// that many columns at a time. No effect when `wrap` is on.
563    #[aliases("ss")]
564    #[validate(validate_sidescroll)]
565    pub Sidescroll: i64 = 0;
566
567    /// Minimum columns kept to the left and right of the cursor when
568    /// the view scrolls horizontally (`wrap` off). Horizontal analog
569    /// of `scrolloff`. Clamped to half the body width at use.
570    #[aliases("siso")]
571    #[validate(validate_sidescrolloff)]
572    pub Sidescrolloff: i64 = 0;
573
574    /// Show whitespace glyphs (trailing spaces, tabs, leading
575    /// indentation) as visible markers. Vim's `:set list`.
576    /// Backing option for `whitespace-show-mode` (M.7.2). The
577    /// renderer's whitespace-painting plumbing lands in M.7.3 --
578    /// today this option is read by the cascade and the mode
579    /// machinery, but the renderer doesn't yet emit decorations.
580    #[aliases("list")]
581    pub Whitespace: bool = false;
582
583    /// Highlight the cursor's current line with a different
584    /// background style. Vim's `:set cursorline`. Backing option
585    /// for `current-line-highlight-mode` (M.7.2). The renderer's
586    /// current-line-highlight pipeline lands in M.7.3.
587    #[aliases("cul", "cursorline")]
588    #[name("current-line-highlight")]
589    pub CursorLine: bool = false;
590
591    /// Synchronise scrolling across all panes that have
592    /// `scrollbind=true`. The option-change handler rebuilds the
593    /// singleton identity-mapper `PaneGroup` to contain exactly
594    /// the current panes with `scrollbind=true`. Vim's
595    /// `:set scrollbind` / `scb`. D.0b.
596    #[aliases("scb")]
597    #[name("scrollbind")]
598    pub Scrollbind: bool = false;
599
600    /// Enable the `emacs-keys` `<C-x>` leader tribute. Default on.
601    /// `:set noemacs-keys` rebuilds the leader layer empty (live),
602    /// reclaiming `<C-x>` for vanilla Normal-mode resolution. See
603    /// `docs/dev/architecture/emacs-keys.md`.
604    #[name("emacs-keys")]
605    pub EmacsKeys: bool = true;
606
607    /// The `emacs-keys` leader prefix — the chord that opens the
608    /// `<C-x>` tribute map (`docs/dev/architecture/emacs-keys.md`).
609    /// Default `<C-x>`. Each binding is `prefix + suffix`, parsed via
610    /// `parse_chord_sequence`; a malformed value degrades to an empty
611    /// tribute (warn, no panic). Live: `:set emacs-keys-prefix=…`
612    /// re-pushes the layer.
613    #[name("emacs-keys-prefix")]
614    pub EmacsKeysPrefix: String = "<C-x>".into();
615
616    /// OM.2b: what `<leader>` expands to in a binding string. Default
617    /// `<Space>` — vim's historical `\\` is an artifact of which keys
618    /// happened to be free in 1991, and the modern vim world maps
619    /// leader to space (nvim-orgmode's documented bindings assume it).
620    ///
621    /// Expansion is **bind-time**, at `try_bind_chord_string`, which
622    /// every plugin mode / `register-binding` / init.rs binding funnels
623    /// through. So this is read once at boot and a later `:set` does
624    /// not move bindings that already landed — live re-expansion is
625    /// the `emacs-keys-prefix` shape and is deliberately not built
626    /// yet. A value that does not parse degrades per-binding to
627    /// `InvalidChord` (skipped + logged), never a panic.
628    ///
629    /// The literal is duplicated from `lattice_keymap::DEFAULT_LEADER`
630    /// rather than imported: `lattice-config` does not depend on
631    /// `lattice-keymap` and should not gain the dependency for a
632    /// default string. They are pinned equal by a test in
633    /// `lattice-host`, where both crates are in scope.
634    #[name("keymap.leader")]
635    pub KeymapLeader: String = "<Space>".into();
636}
637
638// ---- Completion group: insert-completion knobs ----
639
640/// SN.3g: single source for the `gen:snippet` source default priority,
641/// shared by `completion.source.snippet.priority`'s default (below) and
642/// `lattice-snippet`'s `SnippetCompletionMode` contribution, so the two
643/// can't drift. Above buffer-words, below LSP.
644pub const COMPLETION_SOURCE_SNIPPET_DEFAULT_PRIORITY: i64 = 150;
645
646crate::options! {
647    group = crate::Completion;
648
649    /// When the completion pipeline returns exactly one candidate
650    /// at popup-open time, insert it directly instead of showing a
651    /// one-row popup. Only fires at popup-open; narrowing an
652    /// already-open popup to one candidate while typing does not
653    /// auto-insert. Disable with `:set nocompletion.auto_insert_single`
654    /// to always require an explicit confirm.
655    #[name("completion.auto_insert_single")]
656    pub CompletionAutoInsertSingle: bool = true;
657
658    /// Priority bucket for the `gen:lsp-completion` insert-mode
659    /// completion source. Higher numbers float that source's items
660    /// above ties from lower-priority sources
661    /// (`docs/dev/architecture/insert-completion.md` §3.4 / §3.6). Default 200;
662    /// LSP-driven IDE completions usually want to win against
663    /// local buffer words and snippets at tied score.
664    #[name("completion.source.lsp.priority")]
665    #[validate(validate_completion_priority)]
666    pub CompletionSourceLspPriority: i64 = 200;
667
668    /// Priority bucket for the `gen:snippet` insert-mode source.
669    /// Default [`COMPLETION_SOURCE_SNIPPET_DEFAULT_PRIORITY`] (150) --
670    /// above buffer-words, below LSP. Per-language overrides land in
671    /// 4.2.g.5 (3/3); today the value is global.
672    #[name("completion.source.snippet.priority")]
673    #[validate(validate_completion_priority)]
674    pub CompletionSourceSnippetPriority: i64 = COMPLETION_SOURCE_SNIPPET_DEFAULT_PRIORITY;
675
676    /// Priority bucket for the `gen:buffer-words` insert-mode
677    /// source. Default 100 -- baseline; LSP and snippets both
678    /// outrank it at tied matcher score.
679    #[name("completion.source.buffer-words.priority")]
680    #[validate(validate_completion_priority)]
681    pub CompletionSourceBufferWordsPriority: i64 = 100;
682
683    /// Priority bucket for the `gen:tree-sitter-symbol`
684    /// insert-mode source -- definition-position identifiers
685    /// pulled from the buffer's syntax tree. Default 80, below
686    /// buffer-words: when LSP is attached for the language, the
687    /// LSP source has the same names with richer metadata.
688    #[name("completion.source.tree-sitter.priority")]
689    #[validate(validate_completion_priority)]
690    pub CompletionSourceTreeSitterPriority: i64 = 80;
691
692    /// Priority bucket for the `gen:path` insert-mode source --
693    /// filesystem entries surfaced when the cursor sits inside a
694    /// string literal. Default 90 per spec §3.4: below
695    /// buffer-words 100 (which often matches partial paths too)
696    /// and above tree-sitter 80.
697    #[name("completion.source.path.priority")]
698    #[validate(validate_completion_priority)]
699    pub CompletionSourcePathPriority: i64 = 90;
700
701    /// Editor-side commit characters unioned with each LSP
702    /// server's per-item `commitCharacters`. When the
703    /// insert-completion popup is open and the user types one of
704    /// these characters, the focused candidate is accepted before
705    /// the character is inserted. Default empty -- only
706    /// LSP-supplied commit chars fire. Set to e.g. `".,;"` to
707    /// accept on any of those keys globally.
708    #[name("completion.extra_commit_chars")]
709    pub CompletionExtraCommitChars: String = String::new();
710
711    /// Render the top-ranked candidate's suffix as a dimmed
712    /// inline overlay after the cursor while the popup is open
713    /// (Phase 4.2.g.7 polish). Off by default to keep the live
714    /// buffer visually quiet; turn on for a vscode-style preview
715    /// of the most likely completion. Only fires when the cursor
716    /// sits at end-of-line and the top candidate is a
717    /// case-insensitive prefix of the typed query.
718    #[name("completion.ghost_text")]
719    pub CompletionGhostText: bool = false;
720}
721
722// ---- Display group: per-feature buffer-display preferences ----
723//
724// One option per `BufferDisplayCategory` variant, each typed
725// `BufferDisplayPreference`. Default is `Default`, which means
726// "use the category's built-in default" -- the resolver in
727// `App::resolve_display` falls through to `default_display()`
728// when an option resolves to `Default`. Setting a non-default
729// value (e.g. `:set lsp.log.display = split-h`) overrides the
730// dispatch for that category.
731crate::options! {
732    group = crate::Display;
733
734    /// MG.41b: how many rows a **transient** menu may claim before it
735    /// scrolls.
736    ///
737    /// Separate from the picker's own 10-row budget on purpose: a
738    /// picker is *filtered* (you type to narrow, so ten is plenty),
739    /// while a transient is *browsed* — you read the menu to find the
740    /// key. The magit dispatch alone is 25 rows plus group headers, so
741    /// the shared picker cap showed under half of it.
742    ///
743    /// A menu shorter than this claims only its own rows; the value is
744    /// a maximum, not a minimum. Values below 1 clamp to 1.
745    #[name("ui.transient.max-rows")]
746    pub TransientMaxRows: i64 = 20;
747
748    /// EP.4 (2026-08-10): does the language server feed the core error
749    /// list?
750    ///
751    /// On (the default), every coalesced `publishDiagnostics` refreshes
752    /// the `Lsp` slice, so `:problems` / `:copen`, the `:error-list`
753    /// picker and the `:next-error` family cover diagnostics as well as
754    /// compiler output. Off, the diagnostics cache still updates — `[d`
755    /// / `]d`, the inline summary and the signcolumn are unaffected —
756    /// and `:lsp-diagnostics-to-error-list` pulls a snapshot on demand.
757    ///
758    /// Lives in the `lsp` group rather than `diagnostics` because that
759    /// group is *presentation* (`ui.diagnostics.inline`); this is
760    /// producer behaviour. See `error-list.md` §3.2.
761    #[name("lsp.diagnostics-to-error-list")]
762    pub LspDiagnosticsToErrorList: bool = true;
763
764    /// EP.6 (2026-08-11): do references queries also populate the core
765    /// error list?
766    ///
767    /// **Default off**, unlike `lsp.diagnostics-to-error-list`.
768    /// Diagnostics ARE errors and belong in a list called the error
769    /// list; references would change what it means — someone walking
770    /// compile errors with `]qq` should not have that set grow every
771    /// time they look up a symbol. `:lsp-references-to-error-list`
772    /// pushes on demand when this is off. See `error-list.md` §3.2b.
773    #[name("lsp.references-to-error-list")]
774    pub LspReferencesToErrorList: bool = false;
775
776    /// Where `:lsp-status` opens.
777    #[name("lsp.status.display")]
778    pub LspStatusDisplay: lattice_core::ui::display::BufferDisplayPreference =
779        lattice_core::ui::display::BufferDisplayPreference::Default;
780
781    /// Where `:lsp-log` / `:lsp-trace-log` open. Default
782    /// `active-pane` (live-tailed log buffers want to live in
783    /// a real pane).
784    #[name("lsp.log.display")]
785    pub LspLogDisplay: lattice_core::ui::display::BufferDisplayPreference =
786        lattice_core::ui::display::BufferDisplayPreference::Default;
787
788    /// Where `:messages` opens. Default `active-pane` (a
789    /// transcript that streams new entries lives best in a
790    /// pane the user can split / scroll independently).
791    #[name("messages.display")]
792    pub MessagesDisplay: lattice_core::ui::display::BufferDisplayPreference =
793        lattice_core::ui::display::BufferDisplayPreference::Default;
794
795    /// Where `:help <topic>` opens.
796    #[name("help.topic.display")]
797    pub HelpTopicDisplay: lattice_core::ui::display::BufferDisplayPreference =
798        lattice_core::ui::display::BufferDisplayPreference::Default;
799
800    /// Where `:describe-command` / `:describe-buffer` /
801    /// `:describe-key` / `:describe-option` /
802    /// `:describe-event` open.
803    #[name("help.describe.display")]
804    pub HelpDescribeDisplay: lattice_core::ui::display::BufferDisplayPreference =
805        lattice_core::ui::display::BufferDisplayPreference::Default;
806
807    /// Where `:apropos <pattern>` opens.
808    #[name("help.apropos.display")]
809    pub HelpAproposDisplay: lattice_core::ui::display::BufferDisplayPreference =
810        lattice_core::ui::display::BufferDisplayPreference::Default;
811
812    /// Where state-listing help views open (`:ls`, `:keymap`,
813    /// `:options`, `:describe-events`, ...).
814    #[name("help.list.display")]
815    pub HelpListDisplay: lattice_core::ui::display::BufferDisplayPreference =
816        lattice_core::ui::display::BufferDisplayPreference::Default;
817
818    /// Where the hover popup (`K`) renders. Default
819    /// `floating-cursor` (popup floats on the doc; the doc
820    /// keeps focus); user can flip to `popup-cursor` (focused
821    /// popup) or `active-pane`.
822    #[name("hover.display")]
823    pub HoverDisplay: lattice_core::ui::display::BufferDisplayPreference =
824        lattice_core::ui::display::BufferDisplayPreference::Default;
825
826    /// Where signature help renders. Same default-shape as
827    /// hover (cursor-anchored floating).
828    #[name("signature.display")]
829    pub SignatureDisplay: lattice_core::ui::display::BufferDisplayPreference =
830        lattice_core::ui::display::BufferDisplayPreference::Default;
831
832    /// Where the *selected* buffer / location lands after a
833    /// picker accept (`:diagnostics`, `:references`,
834    /// `:symbol`, `:Files`, `:buffers`, `:lsp-server-log`).
835    /// Default `active-pane`; `split-h` / `split-v` open the
836    /// pick in a new sibling pane.
837    #[name("picker.result.display")]
838    pub PickerResultDisplay: lattice_core::ui::display::BufferDisplayPreference =
839        lattice_core::ui::display::BufferDisplayPreference::Default;
840
841    // ---- M.7.3 whitespace decoration glyphs ----
842    //
843    // Five discrete typed glyphs, replacing vim's encoded
844    // `listchars=tab:→\ ,trail:·,space:·,...` blob with a
845    // properly typed surface that's discoverable through
846    // `:options` and `:describe-option`. Each option is a
847    // String (single visible glyph in v1; future combining
848    // sequences can land without an option-shape change).
849    // Empty string ⇒ that whitespace category is not decorated.
850    //
851    // Defaults follow emacs `whitespace-mode`'s canonical
852    // visible set: tabs + trailing + leading. Mid-text spaces
853    // and end-of-line markers are opt-in (most users find
854    // them noisy).
855    //
856    // All five flow through the renderer's whitespace pre-pass
857    // when `whitespace-show-mode` is active (M.7.2 minor) /
858    // `:set list` (M.7.1 cascade); see `Whitespace` in the
859    // Editor group.
860
861    /// Glyph rendered in place of the tab character when
862    /// whitespace decoration is active. Followed by a
863    /// space-pad to the next tabstop column. Empty string ⇒
864    /// tabs render bare. Default `→`.
865    #[name("display.whitespace.tab")]
866    pub WhitespaceTab: String = "→".into();
867
868    /// Glyph for trailing whitespace (spaces or tabs at
869    /// end-of-line). Rendered with a `trailing` style (red
870    /// by default; theme-driven). Empty ⇒ no decoration.
871    /// Default `·`.
872    #[name("display.whitespace.trailing")]
873    pub WhitespaceTrailing: String = "·".into();
874
875    /// Glyph for leading whitespace -- non-tab indentation
876    /// at the start of a line. Mirrors emacs
877    /// `whitespace-mode`'s `indentation` highlight.
878    /// Default `·`.
879    #[name("display.whitespace.leading")]
880    pub WhitespaceLeading: String = "·".into();
881
882    /// Glyph for plain spaces in the middle of text
883    /// (between non-whitespace characters). Most users find
884    /// this loud; default empty. Set to `·` to mirror
885    /// emacs's `space-mark`.
886    #[name("display.whitespace.space")]
887    pub WhitespaceSpace: String = String::new();
888
889    /// Glyph at end-of-line (vim's `eol` listchar). Default
890    /// empty; `¬` is the conventional choice.
891    #[name("display.whitespace.eol")]
892    pub WhitespaceEol: String = String::new();
893
894    // ---- IG.2 indentation guides ----
895    //
896    // A vertical rule down the whitespace at each level of
897    // indentation, with the block enclosing the cursor drawn
898    // brighter. Spacing is `shiftwidth` (one level of indent),
899    // not `tabstop` (the width of a tab byte) -- see
900    // `docs/dev/architecture/indent-guides.md`.
901    //
902    // Buffer-local, so a mode whose buffers gain nothing from
903    // guides turns them off through `Mode::options()` rather
904    // than through a kind check in the renderer.
905
906    /// Draw a vertical rule at each level of indentation.
907    #[name("display.indent-guides")]
908    pub IndentGuides: bool = true;
909
910    /// Glyph the TUI substitutes into the guide column. The GPU
911    /// peer paints a one-pixel rule instead and ignores this --
912    /// a terminal cell cannot hold a hairline, so the two peers
913    /// approximate the same rule with the means they have.
914    /// Empty string ⇒ no guides in the TUI.
915    #[name("display.indent-guides.char")]
916    pub IndentGuidesChar: String = "│".into();
917
918    /// Draw the block enclosing the cursor in the
919    /// `indent.guide.active` style rather than `indent.guide`.
920    /// Off leaves every guide uniform.
921    #[name("display.indent-guides.active")]
922    pub IndentGuidesActive: bool = true;
923}
924
925// ---- Picker group: file finder / command palette / grep ----
926
927crate::options! {
928    group = crate::Picker;
929
930    /// Backend binary `:picker grep` shells out to. `"auto"`
931    /// picks the first available of `rg`, `ag`, `grep` in
932    /// PATH at invocation time. Explicit names (`"rg"`,
933    /// `"ag"`, `"grep"`) force a specific binary and surface
934    /// an error if it's not on PATH. Future plugin-shipped
935    /// backends can register additional names; the matching
936    /// logic lives in `lattice_picker::picker_sources::grep`.
937    #[name("picker.grep.backend")]
938    pub PickerGrepBackend: String = String::from("auto");
939
940    /// Maximum number of grep hits to surface in one
941    /// `:picker grep` invocation. Bounds memory + render
942    /// time on huge codebases; users hit this rarely (typical
943    /// pattern matches hundreds of lines, not thousands).
944    #[name("picker.grep.max-hits")]
945    pub PickerGrepMaxHits: i64 = 2000;
946
947    /// Whether picker MRU (frecency) scoring fires at all.
948    /// `false` disables both the bonus snapshot on
949    /// picker-open and the record-on-accept path -- pickers
950    /// rank by pure match score, ignore prior usage.
951    /// Persistence keeps working independently
952    /// (`picker.mru.persist`); flipping enabled back on
953    /// resumes ranking using whatever's still in the cache.
954    #[name("picker.mru.enabled")]
955    pub PickerMruEnabled: bool = true;
956
957    /// Recency half-life for the frecency formula, in
958    /// **days**. Stored as `i64` so `:set
959    /// picker.mru.recency-half-life-days=14` Just Works
960    /// through the parse-int path; the picker's
961    /// `Duration` machinery converts. Smaller values bias
962    /// strongly toward "today's choices"; larger values
963    /// keep historical usage relevant.
964    #[name("picker.mru.recency-half-life-days")]
965    pub PickerMruRecencyHalfLifeDays: i64 = 7;
966
967    /// Maximum number of MRU entries per (source_id) namespace.
968    /// On insert past this cap the lowest-frecency entry in
969    /// the namespace is evicted (prescient-style). Larger
970    /// values keep long-tail usage history; smaller values
971    /// keep the cache lean.
972    #[name("picker.mru.cap-per-namespace")]
973    pub PickerMruCapPerNamespace: i64 = 1000;
974
975    /// Whether the MRU index persists to disk between runs.
976    /// `false` keeps MRU in-memory only; helpful for ephemeral
977    /// sessions or for users who deliberately want a clean
978    /// slate each launch. Default `true` preserves vertico-
979    /// style "yesterday's picks still float."
980    #[name("picker.mru.persist")]
981    pub PickerMruPersist: bool = true;
982
983    /// Whether a picker query is read as a set of whitespace-
984    /// separated components (`true`, the default) or as one
985    /// literal token (`false`, the pre-orderless behaviour).
986    ///
987    /// With orderless on, `pick refil` matches
988    /// `lattice-picker/src/refilter.rs` regardless of which
989    /// fragment the user recalls first; `!frag` excludes rows
990    /// containing `frag`, and `foo\ bar` matches a literal
991    /// space. A query with no whitespace behaves identically
992    /// either way, so this only changes multi-word queries.
993    #[name("picker.orderless")]
994    pub PickerOrderless: bool = true;
995
996    /// Whether sending a picker's filtered rows to the error list with
997    /// `<C-q>` also opens the `*problems*` view over the result.
998    ///
999    /// On (the default) matches telescope's `<C-q>`, which populates the
1000    /// quickfix list AND opens it — the results are on screen ready to
1001    /// walk. Off is the vim `:grep` habit: the list is populated silently
1002    /// and you `:copen` / `:cnext` when you choose. Either way the entries
1003    /// land in the error list and `:cnext` / `]q` walk them, so this only
1004    /// changes whether the view pops up on send.
1005    #[name("picker.send-opens-problems")]
1006    pub PickerSendOpensProblems: bool = true;
1007
1008    /// Where the picker UI is drawn. `"minibuffer"` renders
1009    /// vertico-style: prompt sits on the cmdline row and the
1010    /// candidate list fans above it (TUI) / above the status
1011    /// line (GPUI), keeping the buffer fully visible.
1012    /// `"popup"` renders a centred overlay floating over the
1013    /// buffer area (terminal-friendly when narrow, common in
1014    /// IDE-style editors). Behaviour is identical between the
1015    /// TUI and GPUI peers so users carry the same muscle
1016    /// memory across them. Future variants (`"split"`,
1017    /// `"sidebar"`) may be added without breaking this key.
1018    #[name("picker.display")]
1019    #[validate(validate_picker_display)]
1020    pub PickerDisplay: String = String::from("minibuffer");
1021}
1022
1023// ---- LSP group: log + trace knobs ----
1024
1025crate::options! {
1026    group = crate::Lsp;
1027
1028    /// Default LSP log-record minimum level at startup.
1029    /// Accepted values: `error` / `warn` / `info` / `debug`
1030    /// / `trace`. The runtime `:lsp-log-level` command
1031    /// adjusts this live; this option sets the boot value.
1032    ///
1033    /// 4.4.o: seeded into `LspLogger::new` so users who
1034    /// always run with debug-level LSP traces don't have to
1035    /// `:set` it post-boot.
1036    #[name("lsp.log_level")]
1037    #[validate(validate_log_level)]
1038    pub LspLogLevel: String = String::from("info");
1039
1040    /// Per-server log ring capacity at boot. Each LSP server
1041    /// gets its own bounded ring; smaller values shed older
1042    /// records sooner. `0` is allowed (drops every record at
1043    /// the ring boundary -- useful for tests / sandboxed
1044    /// runs) but typically users keep the 10k default.
1045    ///
1046    /// 4.4.o: the runtime path
1047    /// (`LspLogger::set_default_capacity`) stays for live
1048    /// resizing; this option just seeds the boot value.
1049    #[name("lsp.log_capacity")]
1050    #[validate(validate_log_capacity)]
1051    pub LspLogCapacity: i64 = 10_000;
1052}
1053
1054// AI-1b: AI group -- log knobs for the per-process `AiLogger` log
1055// rings (mirrors the LSP group above; `lattice-ai`'s `AiLogger`
1056// producer already exists (Task 6), boot-time wiring of these
1057// options into it is a later task).
1058
1059crate::options! {
1060    group = crate::Ai;
1061
1062    /// Enables capture of AI-agent output into the per-process log
1063    /// rings. `:set ai.log=false` disables capture.
1064    #[name("ai.log")]
1065    pub AiLog: bool = true;
1066
1067    /// Default minimum log level for AI-agent log records.
1068    /// Accepted values: `error` / `warn` / `info` / `debug` /
1069    /// `trace`.
1070    #[name("ai.log_level")]
1071    #[validate(validate_ai_log_level)]
1072    pub AiLogLevel: String = String::from("info");
1073}
1074
1075// msg-mode.2: messages group — `messages.filter` directives
1076// the `*messages*` buffer's tracing bridge layer.
1077crate::options! {
1078    group = crate::Messages;
1079
1080    /// `tracing-subscriber::EnvFilter` directive controlling
1081    /// which `tracing::*` events the boot-installed
1082    /// `MessagesLayer` captures into `*messages*`. Accepts:
1083    ///
1084    /// - A single level (`info` / `warn` / `error` / `debug` /
1085    ///   `trace`).
1086    /// - Per-target directives (`lsp=debug`).
1087    /// - Comma-separated combinations
1088    ///   (`editor=info,lsp=debug,grammar=trace`).
1089    ///
1090    /// Live-editable via `:set messages.filter=...`; the
1091    /// runtime's reload-handle (installed at boot alongside
1092    /// the layer) swaps the filter without restarting the
1093    /// editor. Default `info`: every `info!` / `warn!` /
1094    /// `error!` event in the editor flows into `*messages*`,
1095    /// `debug!` and `trace!` are dropped at the filter.
1096    #[name("messages.filter")]
1097    #[validate(validate_messages_filter)]
1098    pub MessagesFilter: String = String::from("info");
1099}
1100
1101// Issue #29 (2026-05-22): tabline group — `tabline.show`
1102// controls when the tab strip is visible. Mirrors vim's
1103// `:set showtabline` (0/1/2) but uses readable labels.
1104crate::options! {
1105    group = crate::Tabline;
1106
1107    /// When to paint the tabline at the top of the screen.
1108    ///
1109    /// - `never`  — never show the tabline (no row reserved).
1110    /// - `auto`   — show only when more than one tab is open
1111    ///              (default; matches vim's `showtabline=1`).
1112    /// - `always` — always show, even for one tab.
1113    ///
1114    /// Live-editable via `:set tabline.show=<value>`. The
1115    /// renderer's per-frame layout pass reads the published
1116    /// value when deciding how much vertical space to reserve.
1117    #[name("tabline.show")]
1118    pub TablineShowOption: lattice_core::ui::tab::TablineShow =
1119        lattice_core::ui::tab::TablineShow::Auto;
1120}
1121
1122// Terminal-mode T2.b.0 (2026-05-25): terminal group — knobs that
1123// affect every PTY-backed buffer. `terminal.esc-exits` is the
1124// first; T2.b/T4 grow the group with `terminal.shell`,
1125// `terminal.scrollback-lines`, `terminal.refresh-hz`, etc.
1126crate::options! {
1127    group = crate::Terminal;
1128
1129    /// When `true`, pressing `<Esc>` inside Terminal-Insert exits
1130    /// back to Normal-in-terminal (so `:q`, motions, and the rest
1131    /// of the vim grammar are reachable without the `<C-\><C-n>`
1132    /// chord). When `false`, `<Esc>` encodes to `\x1b` and goes
1133    /// to the PTY — nested programs (vim, htop, less) keep their
1134    /// own Esc semantics.
1135    ///
1136    /// Default `true`: matches the table-stakes terminal UX of
1137    /// modern editors (VS Code, Helix). Power users running vim
1138    /// inside `:terminal` flip it off and use `<C-\><C-n>` to
1139    /// exit (added by T2.c).
1140    #[name("terminal.esc-exits")]
1141    pub TerminalEscExits: bool = true;
1142
1143    /// Maximum scrollback ring size (lines). Set to `0` to
1144    /// disable scrollback entirely (saves RAM on long-running
1145    /// terminals with chatty output). Default `10000` matches
1146    /// the user-facing `docs/user/terminal-mode.md` table and what
1147    /// most modern terminal emulators ship with.
1148    ///
1149    /// Capped at 1_000_000 — beyond that the ring's memory
1150    /// footprint dwarfs every other editor allocation and the
1151    /// search hot path slows to a crawl. Users who genuinely
1152    /// want unbounded history should pipe the output to a file
1153    /// instead and `:e` it as a Document buffer.
1154    #[name("terminal.scrollback-lines")]
1155    #[validate(validate_terminal_scrollback_lines)]
1156    pub TerminalScrollbackLines: i64 = 10_000;
1157}
1158
1159// PR.2 (2026-08-21): project group. One option — the ordered marker set
1160// `lattice_core::MarkerResolver` walks upward for. Extending it is what
1161// a new ecosystem needs instead of a release, which is most of what a
1162// detector-plugin seam would have bought
1163// (`docs/dev/architecture/project-resolution.md` §9).
1164crate::options! {
1165    group = crate::Project;
1166
1167    /// Filenames or directory names whose presence marks a project
1168    /// root, in priority order. The walk starts at the buffer's own
1169    /// directory and stops at the **first** directory containing any of
1170    /// these, so a crate inside a Cargo workspace is its own project.
1171    ///
1172    /// Order decides which marker is *reported* when a directory holds
1173    /// several: with the default list a git repository that is also a
1174    /// crate reports `.git`.
1175    ///
1176    /// Replaces rather than extends — `:set project.root-markers?`
1177    /// shows the full current list, which is what you are editing. A
1178    /// buffer whose tree contains no marker at all roots at the working
1179    /// directory.
1180    #[name("project.root-markers")]
1181    pub ProjectRootMarkers: crate::RootMarkers = crate::RootMarkers::default();
1182}
1183
1184// ML.5 (2026-06-21): modeline group — per-zone element layout +
1185// separator for the configurable element-system modeline
1186// (`docs/dev/architecture/modeline.md` §11). The three zone options
1187// hold a `ModelineZone` (the first list-valued option): `Auto` (the
1188// default — descriptor-driven placement, so a newly-registered mode
1189// element auto-appears) or an explicit ordered element-id list.
1190// TOML uses Helix-shaped arrays (`left = ["core.mode", "core.path"]`);
1191// `:set ui.modeline.left=core.mode,core.path` uses the comma form.
1192crate::options! {
1193    group = crate::Modeline;
1194
1195    /// Left-zone element layout — ordered element ids assigned to the
1196    /// left (flush-left) zone, e.g. `["core.mode", "core.path"]`.
1197    /// `auto` (the default) uses each registered element's own
1198    /// descriptor placement. An explicit list shows exactly those ids,
1199    /// in order; unknown ids are skipped + logged. An empty list
1200    /// (`[]`) is an explicitly-blank zone.
1201    #[name("ui.modeline.left")]
1202    pub ModelineLeft: crate::ModelineZone = crate::ModelineZone::Auto;
1203
1204    /// Center-zone element layout (centered in the gap between Left and
1205    /// Right). `auto` (default) is descriptor-driven; built-ins place
1206    /// nothing here, so the effective default is empty. Custom / plugin
1207    /// elements live here.
1208    #[name("ui.modeline.center")]
1209    pub ModelineCenter: crate::ModelineZone = crate::ModelineZone::Auto;
1210
1211    /// Right-zone element layout (the block is right-aligned, ids in
1212    /// left→right order), e.g. `["lsp", "core.position", "core.lang"]`.
1213    /// `auto` (default) is descriptor-driven.
1214    #[name("ui.modeline.right")]
1215    pub ModelineRight: crate::ModelineZone = crate::ModelineZone::Auto;
1216
1217    /// Separator inserted between elements within a zone. A non-blank
1218    /// value is auto-padded with a space on each side at render time
1219    /// (so `:set ui.modeline.separator=|` shows ` | ` — you give the
1220    /// glyph, the renderer owns the spacing). Blank (the default) ⇒ a
1221    /// single space between elements.
1222    #[name("ui.modeline.separator")]
1223    pub ModelineSeparator: String = " ".into();
1224
1225    /// Columns of blank margin at the start (before the Left zone) and
1226    /// end (after the Right zone) of the modeline row — the row's
1227    /// left/right breathing room. Default 1; `0` flushes content to the
1228    /// pane edges.
1229    #[name("ui.modeline.padding")]
1230    #[validate(validate_modeline_padding)]
1231    pub ModelinePadding: i64 = 1;
1232}
1233
1234// MO.1 (2026-08-21): mouse reporting.
1235crate::options! {
1236    group = crate::group::Mouse;
1237
1238    /// Whether the editor captures mouse events from the terminal.
1239    ///
1240    /// With it on: the wheel scrolls the pane under the pointer, a
1241    /// click positions the cursor, a drag selects in Visual mode, and
1242    /// modeline elements that declare an `on_click` are clickable.
1243    ///
1244    /// **Default `true` as of MO.2, and the trade is worth stating.**
1245    /// Capture takes the mouse away from the terminal emulator, so the
1246    /// emulator's own click-drag selection and middle-click paste stop
1247    /// working inside Lattice unless the terminal offers a Shift-drag
1248    /// override (most do; not all). MO.1 defaulted this off because the
1249    /// only thing capture bought was modeline clicks — a bad trade
1250    /// against a capability every terminal user already has. Now that
1251    /// the editor body answers the mouse, the trade goes the other way
1252    /// for most users, and the ones it does not suit have one line of
1253    /// config:
1254    ///
1255    /// ```toml
1256    /// [ui]
1257    /// mouse = false
1258    /// ```
1259    ///
1260    /// `:set ui.mouse=false` does the same thing for the session; the
1261    /// TUI mirrors the option into the terminal's reporting state
1262    /// without a restart.
1263    ///
1264    /// Terminal buffers get editor semantics like any other buffer.
1265    /// Passthrough to a child pty is a separate mechanism and is not
1266    /// built; until it is, a full-screen program inside `:terminal`
1267    /// does not see the mouse.
1268    ///
1269    /// Ignored by the GPUI peer, which owns its window's input and
1270    /// therefore takes nothing away by listening for mouse events.
1271    #[name("ui.mouse")]
1272    pub MouseEnabled: bool = true;
1273}
1274
1275// L4 (2026-06-21): diagnostics group — inline end-of-line diagnostic
1276// summary presentation (`lsp-architecture.md` §15). `inline` scopes the
1277// summary (off / cursor-line / all); `inline-min-severity` filters which
1278// diagnostics count. Both are read host-side to gate + compute the
1279// cursor-line summary (L4a.2).
1280crate::options! {
1281    group = crate::Diagnostics;
1282
1283    /// Where the inline (end-of-line virtual-text) diagnostic summary
1284    /// renders: `off`, `cursor-line` (the default — cursor line only,
1285    /// idle-gated, Insert-suppressed), or `all` (every viewport line).
1286    #[name("ui.diagnostics.inline")]
1287    pub DiagnosticsInlineOption: crate::DiagnosticsInline = crate::DiagnosticsInline::CursorLine;
1288
1289    /// Least-severe diagnostic level included in the inline summary:
1290    /// `error`, `warning`, `info`, or `hint` (the default — include
1291    /// everything). A diagnostic shows when it is as-or-more severe.
1292    #[name("ui.diagnostics.inline-min-severity")]
1293    pub DiagnosticsMinSeverityOption: crate::DiagnosticsSeverity =
1294        crate::DiagnosticsSeverity::Hint;
1295}
1296
1297// M.2.0c: `CoreOptions` struct and `register_core_options`
1298// helper retired. Built-in options self-register via the
1299// macro-generated `register_fn` thunks (`OPTION_DECLS` linkme
1300// slice); consumers boot via `ConfigRegistry::init_from_linkme()`
1301// and read via `config.get_typed::<Tabstop>()`.
1302
1303#[cfg(test)]
1304mod tests {
1305    #![allow(
1306        clippy::unwrap_used,
1307        clippy::panic,
1308        clippy::assertions_on_constants,
1309        unsafe_code
1310    )]
1311    use super::*;
1312    use crate::option_decl::OptionDecl;
1313    use crate::registry::ConfigRegistry;
1314
1315    #[test]
1316    fn type_keyed_reads_after_init_from_linkme() {
1317        let r = ConfigRegistry::new();
1318        r.init_from_linkme();
1319        assert_eq!(*r.get_typed::<Tabstop>().unwrap(), 4);
1320        assert!(*r.get_typed::<Number>().unwrap());
1321        assert!(!*r.get_typed::<RelativeNumber>().unwrap());
1322        assert!(!*r.get_typed::<Wrap>().unwrap());
1323        assert!(!*r.get_typed::<ReadOnly>().unwrap());
1324        assert_eq!(
1325            *r.get_typed::<FoldMethodOption>().unwrap(),
1326            FoldMethod::Manual
1327        );
1328        assert_eq!(*r.get_typed::<Scrolloff>().unwrap(), 0);
1329        assert!(*r.get_typed::<CompletionAutoInsertSingle>().unwrap());
1330        assert_eq!(*r.get_typed::<CompletionSourceLspPriority>().unwrap(), 200);
1331    }
1332
1333    #[test]
1334    fn read_only_is_not_customizable() {
1335        // ReadOnly is mode-driven, not user-typed config -- it
1336        // should be hidden from `:set` autocomplete and the
1337        // future `:customize` form.
1338        assert!(!ReadOnly::CUSTOMIZABLE);
1339    }
1340
1341    #[test]
1342    fn completion_source_priority_validate_rejects_out_of_range() {
1343        let r = ConfigRegistry::new();
1344        r.init_from_linkme();
1345        assert!(r.set_typed::<CompletionSourceLspPriority>(-1).is_err());
1346        assert!(r.set_typed::<CompletionSourceLspPriority>(10_000).is_err());
1347        assert!(r.set_typed::<CompletionSourceLspPriority>(0).is_ok());
1348        assert!(r.set_typed::<CompletionSourceLspPriority>(9999).is_ok());
1349    }
1350
1351    // ---- RF.0: the reflow + formatter-chain option surface ----
1352
1353    /// The whole surface is declared in one slice; this is the test that
1354    /// makes that worth doing. Names, aliases and defaults asserted
1355    /// together, so a later slice lighting one of them up cannot quietly
1356    /// rename or re-default it.
1357    #[test]
1358    fn the_reflow_option_surface_registers_with_its_documented_defaults() {
1359        let r = ConfigRegistry::new();
1360        r.init_from_linkme();
1361
1362        assert_eq!(*r.get_typed::<TextWidth>().unwrap(), 80);
1363        assert_eq!(
1364            *r.get_typed::<AutoWrapOption>().unwrap(),
1365            AutoWrap::Comments
1366        );
1367        assert_eq!(r.lookup("tw").unwrap().name(), "textwidth");
1368        assert_eq!(r.lookup("aw").unwrap().name(), "autowrap");
1369
1370        // The two native-by-default intents.
1371        for (chain, what) in [
1372            (r.get_typed::<FormatIndentChain>().unwrap(), "format.indent"),
1373            (r.get_typed::<FormatReflowChain>().unwrap(), "format.reflow"),
1374        ] {
1375            assert_eq!(
1376                *chain,
1377                ProviderChain::of(FormatProvider::Native),
1378                "{what} defaults to the built-in engine"
1379            );
1380        }
1381
1382        // `format.reformat`'s default IS `:format`'s pre-RF.5 cascade,
1383        // in order. If this ever drifts, the refactor changed behaviour
1384        // rather than relocating it.
1385        assert_eq!(
1386            *r.get_typed::<FormatReformatChain>().unwrap(),
1387            ProviderChain::new(vec![FormatProvider::Lsp, FormatProvider::LangDefault]),
1388        );
1389    }
1390
1391    /// `textwidth=0` is vim's "off" and is rejected here, because
1392    /// `autowrap=off` already says that and `textwidth` only ever means
1393    /// "the column reflow targets". The error has to point at the option
1394    /// that actually does what the user was reaching for — a bare "out
1395    /// of range" would leave them guessing.
1396    #[test]
1397    fn textwidth_zero_is_rejected_and_the_message_names_autowrap() {
1398        let r = ConfigRegistry::new();
1399        r.init_from_linkme();
1400        let err = r.parse_and_set_command("textwidth=0").unwrap_err();
1401        let msg = format!("{err}");
1402        assert!(msg.contains("textwidth out of range"), "{msg}");
1403        assert!(
1404            msg.contains("autowrap=off"),
1405            "the message must name the option that turns wrapping off: {msg}"
1406        );
1407        assert!(r.parse_and_set_command("textwidth=100").is_ok());
1408    }
1409
1410    /// A rejected chain must leave the previous one installed. The
1411    /// hazard this guards is a half-applied list: parsing rung by rung
1412    /// and committing as it goes would leave `format.reformat=lsp,nativ`
1413    /// with a one-rung chain and no error the user connects to it.
1414    #[test]
1415    fn a_rejected_chain_leaves_the_previous_value_intact() {
1416        let r = ConfigRegistry::new();
1417        r.init_from_linkme();
1418        r.parse_and_set_command("format.reformat=lsp,native")
1419            .unwrap();
1420        let before = (*r.get_typed::<FormatReformatChain>().unwrap()).clone();
1421
1422        let err = r
1423            .parse_and_set_command("format.reformat=lsp,nativ")
1424            .unwrap_err();
1425        assert!(format!("{err}").contains("nativ"), "{err}");
1426        assert_eq!(
1427            *r.get_typed::<FormatReformatChain>().unwrap(),
1428            before,
1429            "a rejected value must not partially apply"
1430        );
1431    }
1432
1433    /// The flexibility claim from the design, asserted rather than
1434    /// asserted-about: "LSP should drive my reflow" is one `:set`, not a
1435    /// code path.
1436    #[test]
1437    fn routing_reflow_to_the_server_is_one_set_command() {
1438        let r = ConfigRegistry::new();
1439        r.init_from_linkme();
1440        r.parse_and_set_command("format.reflow=lsp,native").unwrap();
1441        assert_eq!(
1442            *r.get_typed::<FormatReflowChain>().unwrap(),
1443            ProviderChain::new(vec![FormatProvider::Lsp, FormatProvider::Native])
1444        );
1445    }
1446
1447    /// An `external:` rung keeps its whole command line through
1448    /// `:set` — including the flags, which is the entire point of it
1449    /// being the `formatprg` replacement.
1450    #[test]
1451    fn an_external_rung_survives_set_with_its_arguments() {
1452        let r = ConfigRegistry::new();
1453        r.init_from_linkme();
1454        r.parse_and_set_command("format.reformat=external:prettier --stdin-filepath %")
1455            .unwrap();
1456        assert_eq!(
1457            *r.get_typed::<FormatReformatChain>().unwrap(),
1458            ProviderChain::of(FormatProvider::External(
1459                "prettier --stdin-filepath %".to_string()
1460            ))
1461        );
1462    }
1463
1464    #[test]
1465    fn registered_options_are_lookable_by_alias() {
1466        let r = ConfigRegistry::new();
1467        r.init_from_linkme();
1468        assert_eq!(r.lookup("nu").unwrap().name(), "number");
1469        assert_eq!(r.lookup("rnu").unwrap().name(), "relativenumber");
1470        assert_eq!(r.lookup("ic").unwrap().name(), "ignorecase");
1471        assert_eq!(r.lookup("ts").unwrap().name(), "tabstop");
1472        assert_eq!(r.lookup("fen").unwrap().name(), "foldenable");
1473        assert_eq!(r.lookup("fdm").unwrap().name(), "foldmethod");
1474        assert_eq!(r.lookup("so").unwrap().name(), "scrolloff");
1475    }
1476
1477    #[test]
1478    fn tabstop_validate_rejects_out_of_range_with_legacy_message() {
1479        let r = ConfigRegistry::new();
1480        r.init_from_linkme();
1481        let err = r.parse_and_set_command("tabstop=99").unwrap_err();
1482        let msg = format!("{err}");
1483        assert!(msg.contains("tabstop out of range [1, 32]: 99"));
1484    }
1485
1486    #[test]
1487    fn picker_display_default_is_minibuffer() {
1488        let r = ConfigRegistry::new();
1489        r.init_from_linkme();
1490        assert_eq!(
1491            r.get_typed::<PickerDisplay>().unwrap().as_str(),
1492            "minibuffer"
1493        );
1494    }
1495
1496    #[test]
1497    fn picker_display_accepts_popup_and_minibuffer() {
1498        let r = ConfigRegistry::new();
1499        r.init_from_linkme();
1500        assert!(r.set_typed::<PickerDisplay>(String::from("popup")).is_ok());
1501        assert_eq!(r.get_typed::<PickerDisplay>().unwrap().as_str(), "popup");
1502        assert!(
1503            r.set_typed::<PickerDisplay>(String::from("minibuffer"))
1504                .is_ok()
1505        );
1506    }
1507
1508    #[test]
1509    fn picker_display_rejects_unknown_value() {
1510        let r = ConfigRegistry::new();
1511        r.init_from_linkme();
1512        let err = r
1513            .set_typed::<PickerDisplay>(String::from("sidebar"))
1514            .unwrap_err();
1515        let msg = err.to_string();
1516        assert!(
1517            msg.contains("picker.display must be one of"),
1518            "unexpected error message: {msg}"
1519        );
1520    }
1521
1522    #[test]
1523    fn foldmethod_parse_error_preserves_legacy_wording() {
1524        // Wording grew the `lsp` option in 4.4.f; the test
1525        // pins the new shape (legacy bytes-identical
1526        // constraint dropped because the option list itself
1527        // grew).
1528        let r = ConfigRegistry::new();
1529        r.init_from_linkme();
1530        let err = r.parse_and_set_command("foldmethod=xyz").unwrap_err();
1531        let msg = format!("{err}");
1532        assert!(msg.contains("expected `manual`, `indent`, `markdown`, `syntax`, or `lsp`"));
1533    }
1534
1535    // AI-1b: `ai.log` / `ai.log_level` config options. Registration
1536    // only -- the boot-time `AiLogger` wiring is Task 12.
1537
1538    #[test]
1539    fn ai_log_default_is_true() {
1540        let r = ConfigRegistry::new();
1541        r.init_from_linkme();
1542        assert!(*r.get_typed::<AiLog>().unwrap());
1543    }
1544
1545    #[test]
1546    fn ai_log_level_default_is_info() {
1547        let r = ConfigRegistry::new();
1548        r.init_from_linkme();
1549        assert_eq!(r.get_typed::<AiLogLevel>().unwrap().as_str(), "info");
1550    }
1551
1552    #[test]
1553    fn ai_log_level_rejects_invalid() {
1554        let r = ConfigRegistry::new();
1555        r.init_from_linkme();
1556        assert!(r.set_typed::<AiLogLevel>(String::from("bogus")).is_err());
1557        assert!(r.set_typed::<AiLogLevel>(String::from("debug")).is_ok());
1558    }
1559
1560    #[test]
1561    fn ai_options_lookable_by_name() {
1562        let r = ConfigRegistry::new();
1563        r.init_from_linkme();
1564        assert_eq!(r.lookup("ai.log").unwrap().name(), "ai.log");
1565        assert_eq!(r.lookup("ai.log_level").unwrap().name(), "ai.log_level");
1566    }
1567
1568    /// MO.1. The default is the whole point of the option, not an
1569    /// **MO.2 flipped this to `true`, and the argument the old test
1570    /// demanded is this one.**
1571    ///
1572    /// The reasoning for `false` was never "mouse capture is fine to
1573    /// skip" — it was a trade. Capture takes the mouse away from the
1574    /// terminal emulator, so its click-drag selection and middle-click
1575    /// paste stop working while Lattice has it. Under MO.1 the only
1576    /// thing bought in exchange was clickable modeline elements, which
1577    /// is a bad deal against a capability every terminal user already
1578    /// has, so it stayed opt-in "until mouse support is broad enough to
1579    /// be worth the swap".
1580    ///
1581    /// MO.2 is that: the wheel scrolls, a click positions the cursor, a
1582    /// drag selects. The trade now runs the other way for most users,
1583    /// and the cost is recoverable in one line (`ui.mouse = false`,
1584    /// or `:set ui.mouse=false` for the session) — whereas the old
1585    /// default cost every user the feature until they discovered an
1586    /// option they had no reason to look for.
1587    ///
1588    /// Still pinned, for the same reason it was pinned before: this is
1589    /// a deliberate trade with a live cost, not a default that should
1590    /// drift in either direction without someone arguing for it.
1591    #[test]
1592    fn mouse_defaults_on_now_that_the_body_answers_it() {
1593        let r = ConfigRegistry::new();
1594        r.init_from_linkme();
1595        assert!(*r.get_typed::<MouseEnabled>().unwrap());
1596    }
1597
1598    /// …and it must stay switchable off, which is what makes the flip
1599    /// defensible. A user whose terminal has no Shift-drag override
1600    /// needs this to work.
1601    #[test]
1602    fn mouse_can_be_turned_back_off() {
1603        let r = ConfigRegistry::new();
1604        r.init_from_linkme();
1605        r.parse_and_set_command("ui.mouse=false").unwrap();
1606        assert!(!*r.get_typed::<MouseEnabled>().unwrap());
1607    }
1608
1609    #[test]
1610    fn mouse_is_settable_by_name() {
1611        let r = ConfigRegistry::new();
1612        r.init_from_linkme();
1613        assert_eq!(r.lookup("ui.mouse").unwrap().name(), "ui.mouse");
1614        r.parse_and_set_command("ui.mouse=true").unwrap();
1615        assert!(*r.get_typed::<MouseEnabled>().unwrap());
1616        r.parse_and_set_command("ui.mouse=false").unwrap();
1617        assert!(!*r.get_typed::<MouseEnabled>().unwrap());
1618    }
1619}