Skip to main content

lattice_config/
group.rs

1// `linkme`'s distributed slices use `link_section` to
2// aggregate items at link time. Workspace lints `deny`
3// `unsafe_code` (CLAUDE.md), so we opt this module in --
4// the safety argument is that linkme is the standard Rust
5// solution for cross-crate aggregation, the link-section
6// usage is contained to its own machinery, and no raw
7// pointer / unchecked-cast code lives here.
8#![allow(unsafe_code)]
9
10//! Option groups: the user-facing organization unit for
11//! `:customize <name>` (`mode-architecture.md` §6.7.1.1).
12//!
13//! Each group is a unique Rust type implementing [`OptionGroup`].
14//! Pre-registered built-in groups ship with the editor:
15//! `Editor`, `Display`, `Editing`, `Lsp`, `Completion`, `Picker`,
16//! `Filetree`, `Oil`, `Help`, `Appearance`. Plugins can join
17//! existing groups (by referencing the type) or declare new
18//! ones (in their own `<plugin-id>.<group>` namespace).
19//!
20//! Compile-time invariants enforced by the [`crate::groups!`]
21//! macro:
22//!
23//! - Group display names cannot end in `-mode`. The
24//!   modes-vs-groups disambiguation rule
25//!   (`mode-architecture.md` §6.7.1) depends on this -- a name
26//!   ending in `-mode` is a mode; a name not ending in `-mode`
27//!   is a group. The macro emits a `const fn` byte-walk
28//!   assertion at the declaration site; violations are compile
29//!   errors.
30//! - Within a crate, two `groups!` invocations with the same
31//!   identifier are duplicate `struct` definitions ⇒ compile
32//!   error.
33//!
34//! Cross-crate display-name uniqueness is enforced at startup
35//! via [`linkme`] aggregation in [`GROUP_DECLS`].
36
37use std::any::TypeId;
38
39/// Compile-time declaration of one option group. Each group
40/// is a unit struct (typically generated by the [`crate::groups!`]
41/// macro) implementing this trait.
42pub trait OptionGroup: 'static {
43    /// Public display name. Used as the boundary string for
44    /// `:customize <name>` and as the prefix-match key for
45    /// option group queries. Cannot end in `-mode`.
46    const NAME: &'static str;
47
48    /// Doc string. Shown in `:describe-group <name>` and the
49    /// `:customize` group picker.
50    const DOC: &'static str;
51
52    /// `TypeId` for type-driven group lookups.
53    fn type_id() -> TypeId
54    where
55        Self: Sized,
56    {
57        TypeId::of::<Self>()
58    }
59}
60
61/// Metadata captured at macro expansion for one group. Submitted
62/// to the [`GROUP_DECLS`] distributed slice; the registry's
63/// startup loader walks the slice to validate uniqueness and
64/// build the `&str → TypeId` lookup table.
65pub struct OptionGroupMetadata {
66    /// The group's [`OptionGroup::NAME`] — the `:customize <name>` key.
67    pub name: &'static str,
68    /// The group's [`OptionGroup::DOC`].
69    pub doc: &'static str,
70    /// `TypeId::of::<G>` for the group type, held as a function
71    /// pointer so the whole struct stays `const`-constructible; call it
72    /// to get the `TypeId`.
73    pub type_id: fn() -> TypeId,
74}
75
76impl OptionGroupMetadata {
77    /// Capture `G`'s name, doc and type id. `const` so the
78    /// [`crate::groups!`] expansion can build the [`GROUP_DECLS`] entry
79    /// in a `static`.
80    ///
81    /// # Examples
82    ///
83    /// ```
84    /// use lattice_config::{Editor, OptionGroup, OptionGroupMetadata};
85    ///
86    /// const META: OptionGroupMetadata = OptionGroupMetadata::for_group::<Editor>();
87    /// assert_eq!(META.name, "editor");
88    /// assert_eq!((META.type_id)(), std::any::TypeId::of::<Editor>());
89    /// assert_eq!(META.doc, Editor::DOC);
90    /// ```
91    pub const fn for_group<G: OptionGroup>() -> Self {
92        Self {
93            name: G::NAME,
94            doc: G::DOC,
95            type_id: TypeId::of::<G>,
96        }
97    }
98}
99
100/// Distributed slice of every group declared anywhere in the
101/// workspace. Each `groups!` macro invocation submits a
102/// `&'static OptionGroupMetadata` element here. The registry
103/// validates cross-crate uniqueness at startup.
104#[linkme::distributed_slice]
105pub static GROUP_DECLS: [&'static OptionGroupMetadata];
106
107/// `const fn` byte-walk: returns true iff `s` ends in the
108/// literal suffix `-mode`. Used by the `groups!` macro to
109/// assert the disambiguation invariant at compile time.
110///
111/// Returns `false` for strings shorter than the suffix.
112/// Implementation walks bytes from the end -- ASCII-safe
113/// because mode/group names are constrained to lowercase
114/// letters, digits, and hyphens.
115///
116/// # Examples
117///
118/// ```
119/// use lattice_config::ends_with_mode_suffix;
120///
121/// assert!(ends_with_mode_suffix("rust-mode"));
122/// assert!(!ends_with_mode_suffix("editor"));
123/// assert!(!ends_with_mode_suffix("mode")); // no leading hyphen
124/// ```
125pub const fn ends_with_mode_suffix(s: &str) -> bool {
126    let bytes = s.as_bytes();
127    let suffix = b"-mode";
128    let n = bytes.len();
129    let m = suffix.len();
130    if n < m {
131        return false;
132    }
133    let mut i = 0;
134    while i < m {
135        if bytes[n - m + i] != suffix[i] {
136            return false;
137        }
138        i += 1;
139    }
140    true
141}
142
143// ---------------------------------------------------------
144// Pre-registered built-in groups.
145// ---------------------------------------------------------
146
147/// Built-in editor options without a mode-specific prefix
148/// (`tabstop`, `number`, `wrap`, ...). Reserved namespace --
149/// only built-in modes can declare options into this group
150/// (enforced by the macro API surface, not by group access
151/// control; see `mode-architecture.md` §6.8 for the
152/// reservation mechanism).
153pub struct Editor;
154impl OptionGroup for Editor {
155    const NAME: &'static str = "editor";
156    const DOC: &'static str = "Bare-named editor options (tabstop, number, wrap, foldmethod, ...). \
157         Reserved namespace -- plugins must use their own prefix.";
158}
159
160/// Visual-presentation options across modes: line numbers, wrap
161/// continuation marker, gutter contents, current-line highlight,
162/// whitespace visualization.
163pub struct Display;
164impl OptionGroup for Display {
165    const NAME: &'static str = "display";
166    const DOC: &'static str = "Visual-presentation options across modes \
167         (line numbers, wrap, whitespace visualization, ...).";
168}
169
170/// Editing-related options: search behavior, indent, auto-pair,
171/// and similar text-manipulation defaults.
172pub struct Editing;
173impl OptionGroup for Editing {
174    const NAME: &'static str = "editing";
175    const DOC: &'static str = "Editing-related options (search, indent, auto-pair, ...).";
176}
177
178/// LSP umbrella group: collects every option owned by an LSP
179/// mode (`lsp-mode`, `lsp-completion-mode`, `lsp-diagnostics-mode`,
180/// ...). `:customize lsp` shows the union.
181pub struct Lsp;
182impl OptionGroup for Lsp {
183    const NAME: &'static str = "lsp";
184    const DOC: &'static str = "Every option owned by an LSP mode. \
185         `:customize lsp` shows the union sectioned by owning mode.";
186}
187
188/// Completion-related options across providers (LSP completion,
189/// snippets, buffer-words, paths).
190pub struct Completion;
191impl OptionGroup for Completion {
192    const NAME: &'static str = "completion";
193    const DOC: &'static str =
194        "Completion options across providers (LSP, snippets, buffer-words, paths).";
195}
196
197/// Picker (file finder, command palette, symbol search) options.
198pub struct Picker;
199impl OptionGroup for Picker {
200    const NAME: &'static str = "picker";
201    const DOC: &'static str = "File finder, command palette, symbol search.";
202}
203
204/// File-tree mode options.
205pub struct Filetree;
206impl OptionGroup for Filetree {
207    const NAME: &'static str = "filetree";
208    const DOC: &'static str = "File tree navigation buffer.";
209}
210
211/// Oil mode (editable directory buffer) options.
212pub struct Oil;
213impl OptionGroup for Oil {
214    const NAME: &'static str = "oil";
215    const DOC: &'static str = "Oil-style editable directory buffer.";
216}
217
218/// Help / documentation buffer options.
219pub struct Help;
220impl OptionGroup for Help {
221    const NAME: &'static str = "help";
222    const DOC: &'static str = "Help / documentation buffers.";
223}
224
225/// Appearance / theming options (colors, sprite icons, font
226/// rendering).
227pub struct Appearance;
228impl OptionGroup for Appearance {
229    const NAME: &'static str = "appearance";
230    const DOC: &'static str =
231        "Appearance / theming options (colors, sprite icons, font rendering).";
232}
233
234/// `*messages*` audit-log buffer + tracing-bridge options.
235pub struct Messages;
236impl OptionGroup for Messages {
237    const NAME: &'static str = "messages";
238    const DOC: &'static str = "Echo-area + `*messages*` buffer + tracing bridge.";
239}
240
241/// Tabline (tab strip at the top of the screen). Issue #29
242/// (2026-05-22).
243pub struct Tabline;
244impl OptionGroup for Tabline {
245    const NAME: &'static str = "tabline";
246    const DOC: &'static str = "Tabline (vim-style tab strip).";
247}
248
249/// Magit (git porcelain) options. MG.22b — the first options
250/// `lattice-magit` owns; before this it registered none at all, so
251/// every `magit.*` name a user tried failed with `unknown option`.
252pub struct Magit;
253impl OptionGroup for Magit {
254    const NAME: &'static str = "magit";
255    const DOC: &'static str = "Magit git porcelain buffers.";
256}
257
258/// Notification options (§5.9.9) — corner, how many show at once, how
259/// long they stay.
260pub struct Notifications;
261impl OptionGroup for Notifications {
262    const NAME: &'static str = "notifications";
263    const DOC: &'static str = "Corner-anchored notifications.";
264}
265
266/// Terminal-mode (PTY-backed shell buffer) options. Added with
267/// T2.b.0 (`terminal.esc-exits`); T2.b/T4 grow the group with
268/// `terminal.shell`, `terminal.scrollback-lines`, etc.
269pub struct Terminal;
270impl OptionGroup for Terminal {
271    const NAME: &'static str = "terminal";
272    const DOC: &'static str = "PTY-backed terminal buffer.";
273}
274
275/// Project-search options (`:search`, project-search multibuffer,
276/// future LSP-references etc. as those settle). K.4.6 follow-up
277/// (2026-06-02): `search.context_size` added by
278/// `ProjectSearchMode` in `lattice-multibuffer`.
279pub struct Search;
280impl OptionGroup for Search {
281    const NAME: &'static str = "search";
282    const DOC: &'static str =
283        "Project-search options: `:search` clustering, context lines, scan limits.";
284}
285
286/// Snippet-engine options. SN.3b (2026-06-14): `snippet.activation` +
287/// `snippet.languages` are declared by `SnippetMode` in
288/// `lattice-snippet`; this group is the customize/listing umbrella
289/// (`:customize snippet`). Same shape as `Search` above — group
290/// here, options in the owning mode's crate.
291pub struct Snippet;
292impl OptionGroup for Snippet {
293    const NAME: &'static str = "snippet";
294    const DOC: &'static str = "Snippet engine: activation policy + supported-language allowlist.";
295}
296
297/// Modeline (bottom per-pane status row) layout options. ML.5
298/// (2026-06-21): `ui.modeline.{left,center,right}` zone assignment +
299/// `ui.modeline.separator`. The `:customize modeline` umbrella for the
300/// configurable element-system modeline
301/// (`docs/dev/architecture/modeline.md` §11).
302pub struct Modeline;
303impl OptionGroup for Modeline {
304    const NAME: &'static str = "modeline";
305    const DOC: &'static str =
306        "Modeline (bottom per-pane status row): per-zone element layout + separator.";
307}
308
309/// Project-resolution options. PR.2 (2026-08-21): `project.root-markers`,
310/// the marker set every buffer's project root is discovered by
311/// (`docs/dev/architecture/project-resolution.md` §4).
312///
313/// Its own group rather than a field of an existing one because a
314/// project is not a property of the UI, the editor, or any one
315/// subsystem — terminal, compilation, search and the file picker all
316/// root from it, so a user opening `:customize project` is asking one
317/// question about the whole editor.
318pub struct Project;
319impl OptionGroup for Project {
320    const NAME: &'static str = "project";
321    const DOC: &'static str =
322        "Project resolution: the marker set a buffer's project root is discovered by.";
323}
324
325/// Mouse-reporting options. MO.1 (2026-08-21): `ui.mouse`, the single
326/// switch that decides whether the editor asks the terminal for mouse
327/// events at all.
328///
329/// It is its own group rather than a field of `Modeline` because the
330/// modeline is only the first consumer — terminal mouse passthrough
331/// (T4.2) and editor-body click/drag want the same switch, and a
332/// user reading `:customize mouse` is asking one question about the
333/// whole editor, not about a status row.
334pub struct Mouse;
335impl OptionGroup for Mouse {
336    const NAME: &'static str = "mouse";
337    const DOC: &'static str =
338        "Mouse reporting: whether the editor captures mouse events from the terminal.";
339}
340
341/// Diagnostics presentation options. L4 (2026-06-21):
342/// `ui.diagnostics.inline` + `ui.diagnostics.inline-min-severity` drive
343/// the inline end-of-line diagnostic summary (`lsp-architecture.md`
344/// §15). The `:customize diagnostics` umbrella.
345pub struct Diagnostics;
346impl OptionGroup for Diagnostics {
347    const NAME: &'static str = "diagnostics";
348    const DOC: &'static str =
349        "Diagnostics presentation: inline end-of-line summary scope + min-severity.";
350}
351
352/// Per-pane behaviour options. PBH.4: `pane.buffer-history-size`, the
353/// bound on each pane's buffer trail (`<C-6>` / `<C-7>`).
354pub struct Pane;
355impl OptionGroup for Pane {
356    const NAME: &'static str = "pane";
357    const DOC: &'static str = "Per-pane behaviour: the bound on each pane's buffer history trail.";
358}
359
360/// Plugin observability options: `plugin.trace-level` (the global default
361/// boundary-trace verbosity the host's `PluginTracer` gates on). PO.4.3.
362pub struct Plugin;
363impl OptionGroup for Plugin {
364    const NAME: &'static str = "plugin";
365    const DOC: &'static str = "Plugin observability: the global default boundary-trace verbosity.";
366}
367
368/// GPUI window options: OS chrome (`ui.window.decorations`) and
369/// maximize-on-launch (`ui.window.start-maximized`). GPUI peer only.
370pub struct Window;
371impl OptionGroup for Window {
372    const NAME: &'static str = "window";
373    const DOC: &'static str =
374        "GPUI window options (borderless chrome, maximize on launch). GPUI peer only.";
375}
376
377/// AI-agent (ACP client) options. AI-1b: `ai.log` / `ai.log_level`
378/// gate/seed the per-process `AiLogger` log rings (mirroring the LSP
379/// log group above). Boot-time wiring into an `AiLogger` lands in a
380/// later task; this group only makes the options resolvable and
381/// `:set`-able.
382pub struct Ai;
383impl OptionGroup for Ai {
384    const NAME: &'static str = "ai";
385    const DOC: &'static str = "AI-agent (ACP client) options: per-process log capture + level.";
386}
387
388// linkme submissions for the built-in groups.
389// Each submission registers one group's metadata; the registry's
390// startup loader walks GROUP_DECLS and panics on duplicate names.
391//
392// The `static` items are `pub` so cross-crate consumers can also
393// reference the group types directly (the metadata is internal to
394// linkme aggregation).
395
396#[linkme::distributed_slice(GROUP_DECLS)]
397static EDITOR_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Editor>();
398
399#[linkme::distributed_slice(GROUP_DECLS)]
400static DISPLAY_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Display>();
401
402#[linkme::distributed_slice(GROUP_DECLS)]
403static EDITING_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Editing>();
404
405#[linkme::distributed_slice(GROUP_DECLS)]
406static LSP_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Lsp>();
407
408#[linkme::distributed_slice(GROUP_DECLS)]
409static COMPLETION_GROUP_LINK: &OptionGroupMetadata =
410    &OptionGroupMetadata::for_group::<Completion>();
411
412#[linkme::distributed_slice(GROUP_DECLS)]
413static PICKER_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Picker>();
414
415#[linkme::distributed_slice(GROUP_DECLS)]
416static FILETREE_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Filetree>();
417
418#[linkme::distributed_slice(GROUP_DECLS)]
419static OIL_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Oil>();
420
421#[linkme::distributed_slice(GROUP_DECLS)]
422static HELP_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Help>();
423
424#[linkme::distributed_slice(GROUP_DECLS)]
425static APPEARANCE_GROUP_LINK: &OptionGroupMetadata =
426    &OptionGroupMetadata::for_group::<Appearance>();
427
428#[linkme::distributed_slice(GROUP_DECLS)]
429static MESSAGES_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Messages>();
430
431#[linkme::distributed_slice(GROUP_DECLS)]
432static TABLINE_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Tabline>();
433
434#[linkme::distributed_slice(GROUP_DECLS)]
435static TERMINAL_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Terminal>();
436
437#[linkme::distributed_slice(GROUP_DECLS)]
438static SEARCH_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Search>();
439
440#[linkme::distributed_slice(GROUP_DECLS)]
441static SNIPPET_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Snippet>();
442
443#[linkme::distributed_slice(GROUP_DECLS)]
444static MODELINE_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Modeline>();
445
446#[linkme::distributed_slice(GROUP_DECLS)]
447static MOUSE_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Mouse>();
448
449#[linkme::distributed_slice(GROUP_DECLS)]
450static DIAGNOSTICS_GROUP_LINK: &OptionGroupMetadata =
451    &OptionGroupMetadata::for_group::<Diagnostics>();
452
453#[linkme::distributed_slice(GROUP_DECLS)]
454static WINDOW_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Window>();
455
456#[linkme::distributed_slice(GROUP_DECLS)]
457static AI_GROUP_LINK: &OptionGroupMetadata = &OptionGroupMetadata::for_group::<Ai>();
458
459// Compile-time naming-rule assertions for the built-in groups.
460// Verifies the `groups!` macro convention even though these
461// groups are hand-written: no built-in group's name ends in
462// `-mode`. If we add a `groups!` macro that wraps these
463// later, it'll emit the same assertion at the macro call site.
464
465const _: () = {
466    assert!(!ends_with_mode_suffix(Editor::NAME));
467    assert!(!ends_with_mode_suffix(Display::NAME));
468    assert!(!ends_with_mode_suffix(Editing::NAME));
469    assert!(!ends_with_mode_suffix(Lsp::NAME));
470    assert!(!ends_with_mode_suffix(Completion::NAME));
471    assert!(!ends_with_mode_suffix(Picker::NAME));
472    assert!(!ends_with_mode_suffix(Filetree::NAME));
473    assert!(!ends_with_mode_suffix(Oil::NAME));
474    assert!(!ends_with_mode_suffix(Help::NAME));
475    assert!(!ends_with_mode_suffix(Appearance::NAME));
476    assert!(!ends_with_mode_suffix(Terminal::NAME));
477    assert!(!ends_with_mode_suffix(Search::NAME));
478    assert!(!ends_with_mode_suffix(Snippet::NAME));
479    assert!(!ends_with_mode_suffix(Modeline::NAME));
480    assert!(!ends_with_mode_suffix(Diagnostics::NAME));
481    assert!(!ends_with_mode_suffix(Window::NAME));
482    assert!(!ends_with_mode_suffix(Ai::NAME));
483};
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn ends_with_mode_suffix_positive() {
491        assert!(ends_with_mode_suffix("rust-mode"));
492        assert!(ends_with_mode_suffix("lsp-completion-mode"));
493        assert!(ends_with_mode_suffix("a-mode"));
494        assert!(ends_with_mode_suffix("-mode"));
495    }
496
497    #[test]
498    fn ends_with_mode_suffix_negative() {
499        assert!(!ends_with_mode_suffix("editor"));
500        assert!(!ends_with_mode_suffix("lsp"));
501        assert!(!ends_with_mode_suffix("mode"));
502        assert!(!ends_with_mode_suffix("modal"));
503        assert!(!ends_with_mode_suffix(""));
504        assert!(!ends_with_mode_suffix("foo"));
505    }
506
507    #[test]
508    fn builtin_groups_have_distinct_type_ids() {
509        // Trivially true (Rust types are unique by path), but
510        // pinning the invariant catches any accidental shared
511        // alias / re-export collapse.
512        let editor = Editor::type_id();
513        let display = Display::type_id();
514        assert_ne!(editor, display);
515    }
516
517    #[test]
518    fn linkme_aggregates_builtin_groups() {
519        // Walk the slice and confirm every built-in name shows up.
520        let names: Vec<_> = GROUP_DECLS.iter().map(|g| g.name).collect();
521        for expected in [
522            "editor",
523            "display",
524            "editing",
525            "lsp",
526            "completion",
527            "picker",
528            "filetree",
529            "oil",
530            "help",
531            "appearance",
532        ] {
533            assert!(
534                names.contains(&expected),
535                "linkme slice missing group: {expected}; saw {names:?}"
536            );
537        }
538    }
539
540    #[test]
541    fn no_duplicate_group_names_in_slice() {
542        let mut names: Vec<&str> = GROUP_DECLS.iter().map(|g| g.name).collect();
543        names.sort_unstable();
544        let before = names.len();
545        names.dedup();
546        assert_eq!(
547            names.len(),
548            before,
549            "duplicate group display name in linkme slice",
550        );
551    }
552}