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}