Skip to main content

lattice_ui_tui/app/
options.rs

1//! `App::resolved_option`, `OptionCache`, and per-option
2//! lookup helpers -- the App-side surface above
3//! `lattice-config`.
4//!
5//! Methods that live here:
6//! - `recompute_options_for_buffer`,
7//!   `resolved_option<D: OptionDecl>` (the read API the
8//!   render hot path uses).
9//! - `rebuild_option_cache` (refreshes the renderer-visible
10//!   projection from the canonical config).
11//! - `do_set` (`:set foo=bar` body), `drain_option_changes` +
12//!   `apply_option_cascade` (the per-option cascade
13//!   driven from the OptionChanged event bus).
14//! - `do_describe_option` (`:describe-option <name>`),
15//!   `do_list_options` (`:options` listing).
16//!
17//! What does NOT live here: the option *registry* (typed
18//! definitions registered via `linkme` distributed slice
19//! in `lattice-config`), or the `OptionCache` struct (lives
20//! in `app.rs` next to the App field).
21
22use lattice_core::FoldMethod;
23
24use super::App;
25
26impl App {
27    // ---- Typed-options accessors (DESIGN.md §5.12) ----
28    //
29    // The current value of each option lives in `self.editor.config`
30    // behind an `ArcSwap` (single source of truth). These
31    // accessors read from `self.editor.option_cache` -- a derived
32    // projection refreshed via the §5.10 cascade hook on every
33    // `Event::OptionChanged` -- so the renderer's per-line option
34    // checks stay at field-access speed (~1ns) instead of the
35    // ~33ns mutex+ArcSwap+downcast dance per call.
36
37    /// `:set number`. Default `true`. Reads the active buffer's
38    /// resolved value via the hot-path cache.
39    pub fn show_line_numbers(&self) -> bool {
40        self.ad().option_cache.show_line_numbers
41    }
42
43    /// `:set number` for an arbitrary buffer (per-pane resolution).
44    /// Used by inactive-pane render paths so each pane's mode
45    /// stack can drive its own gutter independently of the active
46    /// buffer's settings.
47    pub fn show_line_numbers_for(&self, buffer: crate::buffers::BufferId) -> bool {
48        *self.resolved_option::<lattice_config::Number>(buffer)
49    }
50
51    /// `:set relativenumber`. Default `false`. When true the
52    /// gutter shows distance from the cursor; the cursor's line
53    /// shows its absolute number. Implies `number` (vim's
54    /// behaviour) -- the private `apply_option_cascade` cascade
55    /// hook mirrors that cascade.
56    pub fn relative_line_numbers(&self) -> bool {
57        self.ad().option_cache.relative_line_numbers
58    }
59
60    /// `:set relativenumber` for an arbitrary buffer (per-pane
61    /// resolution). Same shape as [`Self::show_line_numbers_for`].
62    pub fn relative_line_numbers_for(&self, buffer: crate::buffers::BufferId) -> bool {
63        *self.resolved_option::<lattice_config::RelativeNumber>(buffer)
64    }
65
66    /// `:set wrap`. Default `false`. (v1 renderer always
67    /// horizontal-scrolls; this flag is read by future B.3 polish.)
68    pub fn wrap_lines(&self) -> bool {
69        self.ad().option_cache.wrap_lines
70    }
71
72    /// `:set wrap` for an arbitrary buffer (per-pane resolution).
73    /// Same shape as [`Self::show_line_numbers_for`]; used by the
74    /// inactive-pane `FrameView::for_buffer` so each pane wraps per
75    /// its own mode stack (e.g. help-mode's `Wrap = true`) rather
76    /// than inheriting the active buffer's setting.
77    pub fn wrap_lines_for(&self, buffer: crate::buffers::BufferId) -> bool {
78        *self.resolved_option::<lattice_config::Wrap>(buffer)
79    }
80
81    /// `:set signcolumn` for an arbitrary buffer (per-pane
82    /// resolution). Returns whether the gutter sign columns
83    /// (diagnostics severity + diff sign) are reserved. Default
84    /// `true` (always reserve, no layout shift); help / synthetic
85    /// buffers resolve `false` via their mode's `signcolumn=no`
86    /// override. The renderer reads this — never the buffer kind.
87    pub fn sign_column_for(&self, buffer: crate::buffers::BufferId) -> bool {
88        self.resolved_option::<lattice_config::SignColumnOption>(buffer)
89            .reserved()
90    }
91
92    /// `:set ignorecase`. Default `false`.
93    pub fn ignorecase(&self) -> bool {
94        self.ad().option_cache.ignorecase
95    }
96
97    /// `:set tabstop=N`. Default `8`. Stored as `i64` in config
98    /// (the typed system's integer type) and cast back to `u32`
99    /// at cache-rebuild time -- the validate closure on the option
100    /// caps the range to `1..=32` so the cast can never lose bits.
101    pub fn tabstop(&self) -> u32 {
102        self.ad().option_cache.tabstop
103    }
104
105    /// `:set scrolloff=N`. Default `0`. Same `i64`→`u32` shape
106    /// as [`Self::tabstop`]; range `0..=64`.
107    pub fn scrolloff(&self) -> u32 {
108        self.ad().option_cache.scrolloff
109    }
110
111    /// `:set foldmethod=...`. Default [`FoldMethod::Manual`].
112    pub fn foldmethod(&self) -> FoldMethod {
113        self.ad().option_cache.foldmethod
114    }
115
116    /// 5.5.G.23: body migrated to
117    /// [`lattice_host::dispatch::Editor::foldenable`]. Retained as a
118    /// 1-line delegate while the renderer's gutter + motions still
119    /// reach the App surface.
120    pub fn foldenable(&self) -> bool {
121        // Slice 3c.extension.fold-rs: `Editor::foldenable()` reads
122        // `option_cache.foldenable`, and `option_cache` is already
123        // mirrored on the published `ad()` substate. Skip the
124        // actor RPC and read the mirror directly — wait-free.
125        // cfg(test) escape hatch — see `App::cursor()`.
126        #[cfg(test)]
127        {
128            self.editor.foldenable()
129        }
130        #[cfg(not(test))]
131        {
132            self.ad().option_cache.foldenable
133        }
134    }
135
136    /// 5.5.G.23.cmdline: body migrated to
137    /// [`lattice_host::dispatch::Editor::completion_auto_insert_single`].
138    /// Retained as a 1-line delegate; deletion follows when the
139    /// remaining App callers (cmdline + completion arms) retire.
140    pub fn completion_auto_insert_single(&self) -> bool {
141        self.read_editor(move |e| e.completion_auto_insert_single())
142    }
143
144    // ---- Test-only typed setters (kept on the public surface
145    //      because integration tests in render.rs reach for them).
146    //      Production code uses `do_set` which goes through the
147    //      cmdline path. These mirror what `do_set` does sans the
148    //      cmdline parse, calling `apply_post_set` so side effects
149    //      (foldmethod ⇒ recompute, ui.* ⇒ theme refresh, ...) match
150    //      the user-driven path. ----
151
152    // Slice 3c.final.E.swap: `set_foldmethod_for_test` /
153    // `set_foldenable_for_test` / `set_completion_auto_insert_single_for_test`
154    // all live in the `#[cfg(test)] impl App` block below.
155
156    /// Delegate to [`lattice_host::editor::Editor::rebuild_option_cache`].
157    /// Phase 5.5.E.6 moved the body host-side; this wrapper exists
158    /// only so the existing App / test call sites keep compiling
159    /// unchanged. Future slices can drop the wrapper once those sites
160    /// migrate to `app.editor.rebuild_option_cache()`.
161    pub(super) fn rebuild_option_cache(&mut self) {
162        // Slice 3c.final.E.3: route through `mutate_editor`.
163        self.mutate_editor(|e| e.rebuild_option_cache());
164    }
165
166    /// Delegate to
167    /// [`lattice_host::editor::Editor::recompute_options_for_buffer`].
168    /// Phase 5.5.E.6 moved the body host-side; layer ordering and the
169    /// resolver walk are documented on the host method.
170    pub fn recompute_options_for_buffer(&mut self, buffer: crate::buffers::BufferId) {
171        // Slice 3c.final.E.3: route through `mutate_editor`.
172        self.mutate_editor(move |e| e.recompute_options_for_buffer(buffer));
173    }
174
175    /// CSM.3 (insert-completion.md §12.4): recompute the
176    /// `ActiveCompletionSources` buffer-local for `buffer` by
177    /// walking `active_modes[buffer]` and calling
178    /// `mode.completion_sources()` on each. Aggregator reads
179    /// the cached result on the popup-open / refilter path; this
180    /// runs at mode-transition rate, not keystroke rate, so the
181    /// allocation cost is amortised away from the hot path.
182    ///
183    /// Empty in practice today -- no mode contributes a source
184    /// yet. CSM.4 (`buffer-words-mode`) is the first slice that
185    /// lights up the cache; CSM.5 -- CSM.8 add the rest. Until
186    /// then, `populate_insert_completion_sync` reads the cache,
187    /// finds it empty, and falls through to the v1 hardcoded
188    /// calls -- proving the read path works without changing
189    /// behaviour.
190    /// 5.5.F.5.1: see [`lattice_host::dispatch::Editor::recompute_active_completion_sources_for`].
191    pub fn recompute_active_completion_sources_for(&mut self, buffer: crate::buffers::BufferId) {
192        // Slice 3c.final.E.3: route through `mutate_editor`.
193        self.mutate_editor(move |e| e.recompute_active_completion_sources_for(buffer));
194    }
195
196    /// Delegate to [`lattice_host::editor::Editor::resolved_option`].
197    /// Phase 5.5.E.6 moved the body host-side; this wrapper keeps the
198    /// existing hot-path call sites (`app.show_line_numbers_for` etc.)
199    /// compiling against `&App` without a per-site rewrite.
200    pub fn resolved_option<D>(&self, buffer: crate::buffers::BufferId) -> std::sync::Arc<D::Value>
201    where
202        D: lattice_config::OptionDecl + 'static,
203        D::Value: Clone + Send + Sync + 'static,
204    {
205        // Slice 3c.final.E.5i: route through `read_editor`. `D` is
206        // a marker type-parameter (no per-instance state) and
207        // `BufferId` is `Copy`, so the closure satisfies
208        // `Send + 'static`. The host body's Arc lookup is wait-free.
209        self.read_editor(move |e| e.resolved_option::<D>(buffer))
210    }
211
212    /// Delegate to [`lattice_host::editor::Editor::do_set`]. Phase
213    /// 5.5.E.6 moved the cmdline-set body host-side; the host returns
214    /// the [`RendererSignal`] list its cascade enqueued and the
215    /// renderer fans them out through [`Self::handle_renderer_signal`].
216    /// The grammar's `Effect::SetOption` arm now routes through
217    /// [`lattice_host::dispatch::handle_effect`] directly; this
218    /// wrapper is retained for the App-level integration tests that
219    /// exercise the cascade through `a.do_set("...")` (`mode.rs`,
220    /// `completion.rs`, `options.rs` test modules) -- production no
221    /// longer calls it, so `#[allow(dead_code)]` covers the non-test
222    /// build's lint.
223    #[allow(dead_code)]
224    pub(super) fn do_set(&mut self, option: &str) {
225        // Slice 3c.final.E.3: route through `mutate_editor_with`.
226        // Promote `&str` to `String` so the closure is `Send + 'static`.
227        let option_owned = option.to_string();
228        let signals = self.mutate_editor_with(move |e| e.do_set(&option_owned));
229        for sig in signals {
230            self.handle_renderer_signal(sig);
231        }
232    }
233
234    /// Delegate to
235    /// [`lattice_host::editor::Editor::drain_option_changes`]. Phase
236    /// 5.5.E.6 moved the cascade body host-side; the host returns the
237    /// [`RendererSignal`] list its cascade enqueued and the renderer
238    /// fans them out through [`Self::handle_renderer_signal`].
239    /// Why a channel and not a callback: typed-option writes can
240    /// originate from anywhere -- the cmdline, plugin tasks
241    /// (Phase 7), the customize buffer view (post-1.0), or future
242    /// LSP-driven config writes. Routing every cascade through this
243    /// channel keeps the per-option cascade off the publish thread.
244    pub fn drain_option_changes(&mut self) {
245        // Slice 3c.final.E.3: route through `mutate_editor_with`.
246        let signals = self.mutate_editor_with(|e| e.drain_option_changes());
247        for sig in signals {
248            self.handle_renderer_signal(sig);
249        }
250    }
251
252    // 5.5.F.5.4: `mirror_option_to_modes` relocated to
253    // [`lattice_host::dispatch::Editor::mirror_option_to_modes`].
254    // The App-side wrapper deletes entirely — the cascade now runs
255    // synchronously inside `Editor::apply_option_cascade`, and the
256    // App-side `RendererSignal::MirrorOptionToModes` handler retired
257    // alongside.
258
259    // 5.5.F.3: `:describe-option` / `:options` content builders
260    // relocated to
261    // [`lattice_host::dispatch::Editor::build_describe_option_content`]
262    // and [`build_list_options_content`]; the corresponding
263    // `Effect::*` arms now run inside `Editor::handle_effect` and
264    // emit `RendererSignal::DisplayBuffer`. Both were Effect-only
265    // paths with no direct in-App callers, so no thin wrappers
266    // remain App-side.
267
268    /// Drain the structural section at `prefix` (an entry
269    /// matching one of the loader's structural prefixes,
270    /// e.g. `"completion.per-language.markdown"`). Removes it
271    /// from `pending_config_structural_sections`; subsequent
272    /// drains return `None`. Used by the per-language /
273    /// plugin-host layers to consume their TOML config without
274    /// leaving stale entries behind.
275    pub(super) fn take_pending_structural_section(
276        &mut self,
277        full_path: &str,
278    ) -> Option<toml::Table> {
279        // Slice 3c.final.E.swap: mutating map remove via the
280        // actor-routed seam.
281        let full_path = full_path.to_string();
282        self.mutate_editor_with(move |e| e.pending_config_structural_sections.remove(&full_path))
283    }
284
285    /// Iterate the dotted paths of every pending structural
286    /// section whose path starts with `namespace.` (e.g.
287    /// `"completion.per-language"` returns the language ids).
288    /// Returned as owned `String`s to keep the borrow short --
289    /// callers typically follow up with
290    /// `take_pending_structural_section(full)` mutating the map.
291    pub(super) fn pending_structural_section_paths(&self, namespace: &str) -> Vec<String> {
292        // Slice 3c.final.E.swap: read map keys via the actor-
293        // routed seam. Returns owned Vec<String>.
294        let prefix = format!("{namespace}.");
295        self.read_editor(move |e| {
296            e.pending_config_structural_sections
297                .keys()
298                .filter(|k| k.starts_with(&prefix))
299                .cloned()
300                .collect()
301        })
302    }
303
304    /// Drain every `completion.per-language.<lang>` structural
305    /// section the loader bucketed and merge each into
306    /// `self.editor.per_language_completion`. Per-key TOML wins over
307    /// the spec defaults seeded at `App::new`; unset keys leave
308    /// the default in place.
309    ///
310    /// Called by the runtime startup right after
311    /// `load_persistent_config` finishes. Idempotent (the bucket
312    /// empties as we drain). Per-key parse warnings collapse
313    /// into a single echo at `Warn` level the same way the
314    /// loader's other diagnostics do.
315    pub fn apply_per_language_toml_overrides(&mut self) {
316        // Phase 5.8.AA.u: body migrated to
317        // `lattice_host::dispatch::Editor::apply_per_language_toml_overrides`.
318        // App-side tests at `dispatch.rs:2038/2061/2088` keep exercising
319        // the merge via this delegate.
320        // Slice 3c.final.E.3: route through `mutate_editor`.
321        self.mutate_editor(|e| e.apply_per_language_toml_overrides());
322    }
323}
324
325// Phase 5.5.E.6: 4.4.k `lsp_server_scope` relocated to
326// `lattice_host::dispatch::lsp_server_scope` alongside the migrated
327// `apply_option_cascade`. No App-side caller remains.
328
329// Phase 5.8.AA.u: `parse_per_language_overrides_table` migrated to
330// `lattice_host::dispatch::parse_per_language_overrides_table`.
331
332// Slice 3c.final.E.5j — test-fixture surface for direct config
333// setters. Every caller is in a `mod tests` block; production
334// code uses the dispatch cascade via `:set`.
335#[cfg(test)]
336impl App {
337    pub fn set_foldenable_for_test(&mut self, on: bool) {
338        let _ = self
339            .editor
340            .config
341            .set_typed::<lattice_config::FoldEnable>(on);
342        self.drain_option_changes();
343    }
344
345    pub fn set_completion_auto_insert_single_for_test(&mut self, on: bool) {
346        let _ = self
347            .editor
348            .config
349            .set_typed::<lattice_config::CompletionAutoInsertSingle>(on);
350        self.drain_option_changes();
351    }
352
353    pub fn set_foldmethod_for_test(&mut self, fm: FoldMethod) {
354        self.editor
355            .config
356            .set_typed::<lattice_config::FoldMethodOption>(fm)
357            .expect("set foldmethod");
358        self.drain_option_changes();
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    #![allow(clippy::unwrap_used, clippy::panic)]
365
366    use crate::app::test_helpers::{
367        app_in_command_mode, app_with, submit_ex, subscribe_all_events,
368    };
369    use crate::app::*;
370    use lattice_grammar::ModalState;
371    use lattice_protocol::Event;
372
373    // 4.4.k `lsp_server_scope` and its standalone unit test moved
374    // alongside the cascade in 5.5.E.6; coverage lives at
375    // `lattice_host::dispatch` (host-side scope) plus the end-to-end
376    // `fan_out_did_change_configuration` integration tests below
377    // (which exercise the lsp.<server>.* cascade through the
378    // RendererSignal::LspConfigChanged signal).
379
380    // ---- Event::OptionChanged (DESIGN.md §5.10 + §5.12) ----
381
382    #[test]
383    fn event_bus_publishes_option_changed_on_set_assign() {
384        let mut a = app_with("xx", 10);
385        let mut rx = subscribe_all_events(&a);
386        a.editor.set_command_line_text("set tabstop=8");
387        a.editor.modal = ModalState::Command;
388        a.apply(Action::CommandLineSubmit);
389        let mut found_opt = None;
390        while let Ok(evt) = rx.try_recv() {
391            if let Event::OptionChanged { name, old, new } = evt {
392                found_opt = Some((name, old, new));
393                break;
394            }
395        }
396        let (name, old, new) = found_opt.expect("OptionChanged should fire on :set tabstop=8");
397        assert_eq!(name, "tabstop");
398        // Default is 4 (W.4.t); :set tabstop=8 is a real change.
399        assert_eq!(old.as_deref(), Some("4"));
400        assert_eq!(new, "8");
401    }
402
403    #[test]
404    fn event_bus_publishes_option_changed_on_set_negate() {
405        let mut a = app_with("xx", 10);
406        let mut rx = subscribe_all_events(&a);
407        a.editor.set_command_line_text("set nonumber");
408        a.editor.modal = ModalState::Command;
409        a.apply(Action::CommandLineSubmit);
410        let mut found = false;
411        while let Ok(evt) = rx.try_recv() {
412            if let Event::OptionChanged { name, new, .. } = evt
413                && name == "number"
414                && new == "false"
415            {
416                found = true;
417                break;
418            }
419        }
420        assert!(found, ":set nonumber should publish OptionChanged");
421    }
422
423    #[test]
424    fn drain_option_changes_runs_foldmethod_cascade_for_direct_config_writes() {
425        // Architectural test: writes that bypass `:set` -- e.g. a
426        // plugin or the future customize buffer view calling
427        // `config.set` directly -- still trigger the cascade once
428        // `drain_option_changes` runs. Pre-bus the cascade lived
429        // on the cmdline path only; this confirms the migration to
430        // the bus-subscription model fixes that gap.
431        let mut a = app_with("def f():\n    pass\n    pass\n", 10);
432        // No :set involved -- direct write to the registry.
433        a.editor
434            .config
435            .set_typed::<lattice_config::FoldMethodOption>(FoldMethod::Indent)
436            .unwrap();
437        // Folds should not be populated yet -- the cascade is
438        // queued but hasn't been drained.
439        a.drain_option_changes();
440        assert_eq!(a.foldmethod(), FoldMethod::Indent);
441        assert!(
442            !a.editor.folds.is_empty(),
443            "drain_option_changes should run the foldmethod cascade and recompute folds"
444        );
445    }
446
447    #[test]
448    fn drain_option_changes_runs_relativenumber_to_number_cascade() {
449        let mut a = app_with("xx", 10);
450        a.editor
451            .config
452            .set_typed::<lattice_config::Number>(false)
453            .unwrap();
454        a.drain_option_changes();
455        assert!(!a.show_line_numbers());
456        a.editor
457            .config
458            .set_typed::<lattice_config::RelativeNumber>(true)
459            .unwrap();
460        a.drain_option_changes();
461        assert!(a.relative_line_numbers());
462        assert!(
463            a.show_line_numbers(),
464            "relativenumber=true should cascade to number=true via the bus subscription"
465        );
466    }
467
468    #[test]
469    fn rebuild_option_cache_picks_up_whitespace_glyphs() {
470        // M.7.3.a: the OptionCache surfaces 5 typed-option
471        // glyphs as `Option<char>`. Defaults match emacs
472        // whitespace-mode's visible set: tab + trailing +
473        // leading on, space + EOL off.
474        let a = app_with("xx", 10);
475        let cache = &a.editor.option_cache;
476        // Whitespace decoration starts off (the option is
477        // false by default; the mode isn't auto-active).
478        assert!(!cache.show_whitespace);
479        // Glyph defaults from `lattice-config::core_options`.
480        assert_eq!(cache.whitespace_tab, Some('→'));
481        assert_eq!(cache.whitespace_trailing, Some('·'));
482        assert_eq!(cache.whitespace_leading, Some('·'));
483        // Off-by-default categories.
484        assert_eq!(cache.whitespace_space, None);
485        assert_eq!(cache.whitespace_eol, None);
486    }
487
488    #[test]
489    fn rebuild_option_cache_picks_up_current_line_highlight() {
490        // M.7.3.a: cursor-line option projects through the
491        // cache. Default off.
492        let a = app_with("xx", 10);
493        assert!(!a.editor.option_cache.current_line_highlight);
494    }
495
496    #[test]
497    fn whitespace_glyph_set_via_set_propagates_to_cache() {
498        // M.7.3.a: a `:set display.whitespace.tab=⇥` write
499        // flows through the cascade and updates the cache's
500        // `whitespace_tab` to the new glyph.
501        let mut a = app_with("xx", 10);
502        a.do_set("display.whitespace.tab=⇥");
503        a.drain_option_changes();
504        assert_eq!(a.editor.option_cache.whitespace_tab, Some('⇥'));
505        // Empty string disables the category.
506        a.do_set("display.whitespace.tab=");
507        a.drain_option_changes();
508        assert_eq!(a.editor.option_cache.whitespace_tab, None);
509    }
510
511    #[test]
512    fn drain_option_changes_runs_ui_theme_sync_for_direct_writes() {
513        let mut a = app_with("xx", 10);
514        a.editor
515            .config
516            .set_typed::<lattice_host::ui::theme_options::UiDimInactive>(false)
517            .unwrap();
518        a.drain_option_changes();
519        assert!(
520            !a.theme.dim_inactive_panes,
521            "ui.dim_inactive=false should propagate to theme.dim_inactive_panes via the cascade"
522        );
523    }
524
525    #[test]
526    fn ui_set_cascade_keeps_host_theme_and_tui_theme_in_sync() {
527        // T.6.t: the host `Theme` struct is deleted; the typed-options
528        // registry (`config`) is the canonical renderer-neutral state
529        // for the 8 non-style chrome fields, and `theme` is the cached
530        // ratatui-typed adapter rebuilt from config + the resolved table
531        // on `RendererSignal::ThemeChanged`. We exercise a representative
532        // subset (`dim_inactive`, `nerd_fonts`, separator glyph,
533        // separator color) and assert each mutation lands in the cache.
534        let mut a = app_with("xx", 10);
535        // Flip dim_inactive false.
536        a.editor
537            .config
538            .set_typed::<lattice_host::ui::theme_options::UiDimInactive>(false)
539            .unwrap();
540        a.drain_option_changes();
541        assert!(!a.theme.dim_inactive_panes, "tui: dim_inactive flipped");
542        // Flip nerd_fonts on.
543        a.editor
544            .config
545            .set_typed::<lattice_host::ui::theme_options::UiNerdFonts>(true)
546            .unwrap();
547        a.drain_option_changes();
548        assert!(a.theme.nerd_fonts);
549        // Change separator glyph.
550        a.editor
551            .config
552            .set_typed::<lattice_host::ui::theme_options::UiSeparator>("┃".to_string())
553            .unwrap();
554        a.drain_option_changes();
555        assert_eq!(a.theme.pane_separator_vertical, '┃');
556        // Change separator color (named).
557        a.editor
558            .config
559            .set_typed::<lattice_host::ui::theme_options::UiSeparatorColor>("red".to_string())
560            .unwrap();
561        a.drain_option_changes();
562        // T.9: the separator STYLE no longer lives on the host `Theme`;
563        // `:set ui.separator_color=red` now applies a registry override on
564        // the `pane.separator` element, and the TUI native cache
565        // (`a.theme.pane_separator`) is rebuilt from the resolved table on
566        // `RendererSignal::ThemeChanged`. Assert the cache reflects it.
567        assert_eq!(
568            a.theme.pane_separator.fg,
569            Some(ratatui::style::Color::Red),
570            "tui: separator fg=red",
571        );
572    }
573
574    #[test]
575    fn drain_option_changes_handles_chained_cascade_writes() {
576        let mut a = app_with("xx", 10);
577        a.editor
578            .config
579            .set_typed::<lattice_config::Number>(false)
580            .unwrap();
581        a.drain_option_changes();
582        a.editor
583            .config
584            .set_typed::<lattice_config::RelativeNumber>(true)
585            .unwrap();
586        a.drain_option_changes();
587        assert!(a.relative_line_numbers());
588        assert!(a.show_line_numbers());
589        a.drain_option_changes();
590        assert!(a.relative_line_numbers());
591        assert!(a.show_line_numbers());
592    }
593
594    #[test]
595    fn event_bus_does_not_publish_option_changed_on_query() {
596        let mut a = app_with("xx", 10);
597        let mut rx = subscribe_all_events(&a);
598        a.editor.set_command_line_text("set number?");
599        a.editor.modal = ModalState::Command;
600        a.apply(Action::CommandLineSubmit);
601        while let Ok(evt) = rx.try_recv() {
602            assert!(
603                !matches!(evt, Event::OptionChanged { .. }),
604                "query should not publish OptionChanged"
605            );
606        }
607    }
608
609    // ---- :set ----
610
611    #[test]
612    fn set_unknown_option_emits_error() {
613        let mut a = app_with("hello", 10);
614        submit_ex(&mut a, "set frobnicate");
615        let msg = a.editor.last_message.as_ref().unwrap();
616        assert_eq!(msg.level, EchoLevel::Error);
617        assert!(msg.text.contains("frobnicate"));
618    }
619
620    #[test]
621    fn set_unknown_option_errors() {
622        let mut a = app_with("xx", 10);
623        a.editor.set_command_line_text("set whatever");
624        a.editor.modal = ModalState::Command;
625        a.apply(Action::CommandLineSubmit);
626        let msg = a.editor.last_message.as_ref().expect("error");
627        assert!(msg.text.contains("Unknown option"), "got: {}", msg.text);
628    }
629
630    #[test]
631    fn set_no_form_clears_boolean() {
632        let mut a = app_with("xx", 10);
633        a.editor.set_command_line_text("set nonumber");
634        a.editor.modal = ModalState::Command;
635        a.apply(Action::CommandLineSubmit);
636        assert!(!a.show_line_numbers());
637    }
638
639    #[test]
640    fn set_no_form_rejects_non_boolean() {
641        let mut a = app_with("xx", 10);
642        a.editor.set_command_line_text("set notabstop");
643        a.editor.modal = ModalState::Command;
644        a.apply(Action::CommandLineSubmit);
645        let msg = a.editor.last_message.as_ref().expect("error");
646        assert!(msg.text.contains("not a boolean"), "got: {}", msg.text);
647    }
648
649    #[test]
650    fn set_int_out_of_range_errors() {
651        let mut a = app_with("xx", 10);
652        a.editor.set_command_line_text("set tabstop=999");
653        a.editor.modal = ModalState::Command;
654        a.apply(Action::CommandLineSubmit);
655        let msg = a.editor.last_message.as_ref().expect("error");
656        assert!(msg.text.contains("out of range"), "got: {}", msg.text);
657    }
658
659    // ---- :describe-option / :options ----
660
661    #[test]
662    fn describe_option_renders_help_with_metadata() {
663        let mut a = app_with("xx", 10);
664        a.editor.set_command_line_text("describe-option tabstop");
665        a.editor.modal = ModalState::Command;
666        a.apply(Action::CommandLineSubmit);
667        let h = a.popup_help().expect("describe-option help");
668        let body = h.content.as_string();
669        assert!(body.contains("tabstop"));
670        assert!(body.contains("integer"));
671        assert!(body.contains("default"));
672    }
673
674    #[test]
675    fn list_options_includes_every_registered_option() {
676        let mut a = app_with("xx", 10);
677        a.editor.set_command_line_text("options");
678        a.editor.modal = ModalState::Command;
679        a.apply(Action::CommandLineSubmit);
680        let h = a.popup_help().expect("options help");
681        let body = h.content.as_string();
682        assert!(body.contains("number"));
683        assert!(body.contains("tabstop"));
684        assert!(body.contains("scrolloff"));
685    }
686
687    #[test]
688    fn list_options_groups_by_group_and_includes_docs() {
689        let mut a = app_with("xx", 10);
690        a.editor.set_command_line_text("options");
691        a.editor.modal = ModalState::Command;
692        a.apply(Action::CommandLineSubmit);
693        let h = a.popup_help().expect("options help");
694        let body = h.content.as_string();
695        // Group section headers (markdown ##) for the built-in
696        // groups that own options today.
697        assert!(body.contains("## editor"), "missing editor section\n{body}");
698        assert!(
699            body.contains("## completion"),
700            "missing completion section\n{body}"
701        );
702        assert!(
703            body.contains("## display"),
704            "missing display section\n{body}"
705        );
706        // Each option's doc string is included alongside the row.
707        // `tabstop`'s doc starts with "Number of columns a hard tab".
708        assert!(
709            body.contains("Number of columns a hard tab"),
710            "tabstop doc not rendered\n{body}",
711        );
712        // Aliases are surfaced in the option header (`tabstop [ts]`,
713        // `number [nu]`).
714        assert!(body.contains("[ts]"), "tabstop alias missing\n{body}");
715        assert!(body.contains("[nu]"), "number alias missing\n{body}");
716    }
717
718    #[test]
719    fn list_options_hides_non_customizable_options() {
720        // `read-only` is `customizable = false` (mode-driven, not
721        // user-typed); the live reference should hide it.
722        let mut a = app_with("xx", 10);
723        a.editor.set_command_line_text("options");
724        a.editor.modal = ModalState::Command;
725        a.apply(Action::CommandLineSubmit);
726        let h = a.popup_help().expect("options help");
727        let body = h.content.as_string();
728        assert!(
729            !body.contains("**read-only**"),
730            "read-only should be hidden from :options reference\n{body}",
731        );
732    }
733
734    // ---- M.2.1: option resolution per buffer ----
735
736    /// Test fixture: a registered minor mode that contributes
737    /// option overrides via the `overrides!` macro.
738    struct OptionContributingMode {
739        id: lattice_mode::ModeId,
740    }
741
742    impl OptionContributingMode {
743        fn new() -> Self {
744            Self {
745                id: lattice_mode::ModeId::new("test-option-contrib-mode"),
746            }
747        }
748    }
749
750    impl lattice_mode::Mode for OptionContributingMode {
751        type Guard = ();
752        fn id(&self) -> lattice_mode::ModeId {
753            self.id
754        }
755        fn kind(&self) -> lattice_mode::ModeKind {
756            lattice_mode::ModeKind::Minor
757        }
758        fn options(&self) -> lattice_config::OptionOverrideSet {
759            lattice_config::overrides! {
760                lattice_config::Tabstop = 4i64,
761                lattice_config::Number = false,
762            }
763        }
764        fn on_activate(
765            &self,
766            _ctx: lattice_mode::ModeContext,
767        ) -> lattice_mode::LifecycleFuture<'_, ()> {
768            Box::pin(async { Ok(()) })
769        }
770    }
771
772    #[test]
773    fn recompute_options_uses_registry_defaults_when_no_modes() {
774        let mut a = app_with("hi", 5);
775        let buf = a.editor.document_buffer_id;
776        a.recompute_options_for_buffer(buf);
777        let v = a.resolved_option::<lattice_config::Tabstop>(buf);
778        assert_eq!(*v, 4);
779        let n = a.resolved_option::<lattice_config::Number>(buf);
780        assert!(*n);
781    }
782
783    #[test]
784    fn recompute_options_overlays_active_mode_contributions() {
785        let mut a = app_with("hi", 5);
786        let buf = a.editor.document_buffer_id;
787        // See dispatch.rs notes for `make_mut` vs `get_mut`.
788        let mode_id = {
789            let mut registry = (**a.editor.mode_registry.load()).clone();
790            let id = registry
791                .register(OptionContributingMode::new())
792                .expect("register");
793            a.editor.mode_registry.store(std::sync::Arc::new(registry));
794            id
795        };
796        let mut active = lattice_mode::ActiveModes::new();
797        let guards = lattice_mode::GuardStoreHandle::new();
798        a.editor
799            .mode_registry
800            .load_full()
801            .activate_minor(
802                &mut active,
803                &guards,
804                &a.editor.config,
805                &a.editor.event_bus,
806                &a.editor.services,
807                lattice_protocol::ids::BufferId::new(0),
808                mode_id,
809                lattice_mode::CapabilitySet::empty(),
810            )
811            .expect("activate");
812        a.editor.active_modes.insert(buf, active);
813
814        a.recompute_options_for_buffer(buf);
815
816        let v = a.resolved_option::<lattice_config::Tabstop>(buf);
817        assert_eq!(*v, 4);
818        let n = a.resolved_option::<lattice_config::Number>(buf);
819        assert!(!*n);
820    }
821
822    #[test]
823    fn buffer_local_override_beats_mode_contribution() {
824        let mut a = app_with("hi", 5);
825        let buf = a.editor.document_buffer_id;
826        // See dispatch.rs notes for `make_mut` vs `get_mut`.
827        let mode_id = {
828            let mut registry = (**a.editor.mode_registry.load()).clone();
829            let id = registry
830                .register(OptionContributingMode::new())
831                .expect("register");
832            a.editor.mode_registry.store(std::sync::Arc::new(registry));
833            id
834        };
835        let mut active = lattice_mode::ActiveModes::new();
836        let guards = lattice_mode::GuardStoreHandle::new();
837        a.editor
838            .mode_registry
839            .load_full()
840            .activate_minor(
841                &mut active,
842                &guards,
843                &a.editor.config,
844                &a.editor.event_bus,
845                &a.editor.services,
846                lattice_protocol::ids::BufferId::new(0),
847                mode_id,
848                lattice_mode::CapabilitySet::empty(),
849            )
850            .expect("activate");
851        a.editor.active_modes.insert(buf, active);
852
853        let mut local = lattice_config::OptionOverrideSet::new();
854        local.push(lattice_config::OptionOverride::new(
855            std::any::TypeId::of::<lattice_config::Tabstop>(),
856            16i64,
857        ));
858        a.editor.buffer_local_overrides.insert(buf, local);
859
860        a.recompute_options_for_buffer(buf);
861
862        let v = a.resolved_option::<lattice_config::Tabstop>(buf);
863        assert_eq!(*v, 16);
864        let n = a.resolved_option::<lattice_config::Number>(buf);
865        assert!(!*n);
866    }
867
868    #[test]
869    fn resolved_option_falls_back_to_registry_pre_recompute() {
870        let a = app_with("hi", 5);
871        let buf = a.editor.document_buffer_id;
872        let v = a.resolved_option::<lattice_config::Tabstop>(buf);
873        assert_eq!(*v, 4);
874    }
875
876    #[test]
877    fn show_line_numbers_for_resolves_per_buffer() {
878        // Two buffers: the active doc keeps the global default
879        // (true); the second sets a buffer-local override to false.
880        // `show_line_numbers_for` must return the per-buffer
881        // resolved value, not the active buffer's setting.
882        use crate::buffer_registry::{BufferData, BufferEntry, DocumentEntry};
883        use crate::buffers::{BufferFlags, BufferId};
884        let mut a = app_with("hi", 5);
885        let active = a.editor.document_buffer_id;
886        // Manufacture a second document buffer.
887        let other = BufferId::next();
888        let handle = a.editor.document.as_arc();
889        a.editor.buffers.insert(BufferEntry {
890            id: other,
891            flags: BufferFlags::default(),
892            data: BufferData::Document(DocumentEntry { id: other, handle }),
893            name: None,
894        });
895        // Buffer-local: Number = false on `other`.
896        let mut local = lattice_config::OptionOverrideSet::new();
897        local.push(lattice_config::OptionOverride::new(
898            std::any::TypeId::of::<lattice_config::Number>(),
899            false,
900        ));
901        a.editor.buffer_local_overrides.insert(other, local);
902        a.recompute_options_for_buffer(other);
903        // Active buffer keeps the default (true); the override
904        // only applies to `other`.
905        assert!(a.show_line_numbers_for(active));
906        assert!(!a.show_line_numbers_for(other));
907    }
908
909    // ---- M.3.1: ReadOnly option flows from major modes ----
910
911    #[test]
912    fn document_buffer_resolves_read_only_false() {
913        let a = app_with("hi", 5);
914        let buf = a.editor.document_buffer_id;
915        let read_only: bool = *a.resolved_option::<lattice_config::ReadOnly>(buf);
916        assert!(!read_only, "Document buffer should be writable by default");
917    }
918
919    #[test]
920    fn help_buffer_resolves_read_only_true() {
921        let mut a = app_with("hi", 5);
922        let help = crate::help::HelpContent::from_lines("test", vec!["line one".to_string()]);
923        let help_id = a.open_help_in_pane(help);
924        let read_only: bool = *a.resolved_option::<lattice_config::ReadOnly>(help_id);
925        assert!(
926            read_only,
927            "Help buffer should resolve ReadOnly = true via help-mode"
928        );
929    }
930
931    #[test]
932    fn boot_registers_the_builtin_signs() {
933        // SG.4a: diagnostics and diff marks are signs in the same registry a
934        // plugin writes. If this is empty, the host still has privileged
935        // gutter paths and the unification did not happen.
936        let a = app_with("hello", 10);
937        let registry = a
938            .editor
939            .services
940            .get::<lattice_mode::SignRegistryHandle>()
941            .expect("the sign registry is a boot service");
942        let snapshot = registry.load();
943        for name in [
944            "diagnostic.error",
945            "diagnostic.warning",
946            "diagnostic.info",
947            "diagnostic.hint",
948            "diff.add",
949            "diff.change",
950            "diff.remove",
951            "diff.conflict",
952        ] {
953            assert!(
954                snapshot.id_of(name).is_some(),
955                "`{name}` must be a registered sign"
956            );
957        }
958        // And the interned ids on the Editor resolve to them, so a producer
959        // reads a field instead of hashing a name per visible line.
960        let ids = a.editor.builtin_sign_ids;
961        assert_eq!(
962            snapshot.id_of("diagnostic.error"),
963            Some(ids.diagnostic_error)
964        );
965        assert_eq!(snapshot.id_of("diff.add"), Some(ids.diff_add));
966    }
967
968    #[test]
969    fn setting_a_diagnostic_glyph_rewrites_its_sign_without_moving_the_id() {
970        // The glyph is a live option: before SG.4 the renderers read it per
971        // frame, so `:set` took effect on the next paint. A definition is
972        // static by design, so the option writes THROUGH the registry now —
973        // and it must keep the id, or every placement in flight would resolve
974        // to nothing and the marks would blink out on an option change.
975        let mut a = app_with("hello", 10);
976        let registry = a
977            .editor
978            .services
979            .get::<lattice_mode::SignRegistryHandle>()
980            .expect("the sign registry is a boot service");
981        let before = a.editor.builtin_sign_ids.diagnostic_error;
982        assert_eq!(registry.load().get(before).unwrap().glyph_char(false), '■');
983
984        submit_ex(&mut a, "set ui.diagnostic-error-glyph=X");
985
986        let after = a.editor.builtin_sign_ids.diagnostic_error;
987        assert_eq!(before, after, "the id must survive the redefinition");
988        assert_eq!(
989            registry.load().get(after).unwrap().glyph_char(false),
990            'X',
991            "and the new glyph must be what the registry now answers"
992        );
993    }
994
995    #[test]
996    fn read_only_option_is_marked_internal() {
997        use lattice_config::OptionDecl;
998        assert!(
999            !lattice_config::ReadOnly::CUSTOMIZABLE,
1000            "ReadOnly should be non-customizable (mode-driven)"
1001        );
1002    }
1003
1004    #[test]
1005    fn set_number_and_nonumber_toggle_show_line_numbers() {
1006        let mut a = app_with("hello", 10);
1007        assert!(a.show_line_numbers());
1008        submit_ex(&mut a, "set nonumber");
1009        assert!(!a.show_line_numbers());
1010        submit_ex(&mut a, "set number");
1011        assert!(a.show_line_numbers());
1012    }
1013
1014    #[test]
1015    fn set_relativenumber_toggles_flag() {
1016        let mut a = app_with("hello\nworld", 10);
1017        assert!(!a.relative_line_numbers());
1018        submit_ex(&mut a, "set relativenumber");
1019        assert!(a.relative_line_numbers());
1020        assert!(a.show_line_numbers());
1021        submit_ex(&mut a, "set norelativenumber");
1022        assert!(!a.relative_line_numbers());
1023    }
1024
1025    #[test]
1026    fn typing_after_popup_open_live_refilters_candidates() {
1027        // Vertico-style: typing while the popup is open keeps it
1028        // open and re-runs the pipeline against the longer prefix.
1029        let mut a = app_in_command_mode("descr");
1030        a.apply(Action::CommandLineCompleteOrAdvance);
1031        assert!(a.editor.completion_state.is_some());
1032        // Opening the popup inserts the longest common prefix of the
1033        // matches (vim-wildmenu style): every `describe-*` command
1034        // shares `describe-`, so the cmdline extends to it. The
1035        // `descr` the user typed is preserved as `original_line` for
1036        // dismiss-restore; see `open_completion_popup`.
1037        assert_eq!(a.editor.command_line(), "describe-");
1038        let initial_count = a.editor.completion_state.as_ref().unwrap().candidates.len();
1039
1040        // Typing keeps the popup open and re-runs the pipeline against
1041        // the longer prefix (no second LCP rewrite — refresh only
1042        // refilters).
1043        a.apply(Action::CommandLineAppend('k'));
1044        assert!(
1045            a.editor.completion_state.is_some(),
1046            "popup must stay open while filtering"
1047        );
1048        assert_eq!(a.editor.command_line(), "describe-k");
1049        // Typing narrows the prefix -> candidate set should shrink
1050        // or stay equal, never grow (only `describe-key` survives).
1051        let narrowed = a.editor.completion_state.as_ref().unwrap().candidates.len();
1052        assert!(narrowed <= initial_count);
1053        assert!(narrowed >= 1, "describe-key still matches `describe-k`");
1054        // Selection resets to first match (the candidate set
1055        // changed; previous index would be meaningless).
1056        assert_eq!(a.editor.completion_state.as_ref().unwrap().selected, 0);
1057    }
1058
1059    #[test]
1060    fn typing_no_match_keeps_popup_open_with_empty_candidates() {
1061        // Vertico-style: typing past the matchable region leaves the
1062        // popup alive (just empty), so a single backspace can recover.
1063        let mut a = app_in_command_mode("descri");
1064        a.apply(Action::CommandLineCompleteOrAdvance);
1065        for c in "zxqzxqzxq".chars() {
1066            a.apply(Action::CommandLineAppend(c));
1067        }
1068        let state = a
1069            .editor
1070            .completion_state
1071            .as_ref()
1072            .expect("popup must stay open on no-match");
1073        assert!(state.candidates.is_empty());
1074        // Backspacing the noise restores matches.
1075        for _ in 0.."zxqzxqzxq".len() {
1076            a.apply(Action::CommandLineBackspace);
1077        }
1078        assert!(a.editor.completion_state.is_some());
1079        assert!(
1080            !a.editor
1081                .completion_state
1082                .as_ref()
1083                .unwrap()
1084                .candidates
1085                .is_empty()
1086        );
1087    }
1088
1089    #[test]
1090    fn typing_with_no_popup_open_does_not_open_one() {
1091        // Refresh only fires when a popup is already open; bare
1092        // typing without a prior <Tab> stays as it was.
1093        let mut a = app_in_command_mode("desc");
1094        a.apply(Action::CommandLineAppend('r'));
1095        assert!(a.editor.completion_state.is_none());
1096        assert_eq!(a.editor.command_line(), "descr");
1097    }
1098
1099    #[test]
1100    fn fresh_app_has_one_document_pane() {
1101        let a = app_with("xx", 10);
1102        assert_eq!(a.editor.pane_tree.len(), 1);
1103        assert_eq!(a.editor.active_buffer, BufferKind::Document);
1104        let active = a.editor.pane_tree.active();
1105        assert_eq!(active.buffer, BufferKind::Document);
1106        assert_eq!(active.buffer_id, a.editor.document_buffer_id);
1107    }
1108
1109    #[test]
1110    fn fresh_app_registers_initial_document() {
1111        let a = app_with("xx", 10);
1112        // Listed-buffer view filters out synthetic LSP / messages
1113        // buffers, leaving just the user's document.
1114        assert_eq!(a.editor.buffers.listed_ids_sorted().len(), 1);
1115        assert!(
1116            a.editor
1117                .buffers
1118                .contains_document(a.editor.document_buffer_id)
1119        );
1120    }
1121
1122    #[test]
1123    fn set_tabstop_assignment_updates_field() {
1124        let mut a = app_with("xx", 10);
1125        a.editor.set_command_line_text("set tabstop=4");
1126        a.editor.modal = ModalState::Command;
1127        a.apply(Action::CommandLineSubmit);
1128        assert_eq!(a.tabstop(), 4);
1129    }
1130
1131    #[test]
1132    fn set_tabstop_via_alias() {
1133        let mut a = app_with("xx", 10);
1134        a.editor.set_command_line_text("set ts=2");
1135        a.editor.modal = ModalState::Command;
1136        a.apply(Action::CommandLineSubmit);
1137        assert_eq!(a.tabstop(), 2);
1138    }
1139
1140    #[test]
1141    fn effective_completion_for_markdown_default_excludes_lsp() {
1142        // Spec default: markdown drops LSP for prose. The
1143        // App's seeded map should reflect this without any
1144        // TOML being loaded.
1145        let a = app_with("", 5);
1146        let eff = a.effective_completion_for("markdown");
1147        let lsp_id =
1148            lattice_completion::SourceId::new(lattice_completion::LSP_COMPLETION_SOURCE_ID);
1149        assert!(!eff.source_enabled(&lsp_id), "markdown default drops LSP");
1150        let snippet_id = lattice_completion::SourceId::new(lattice_completion::SNIPPET_SOURCE_ID);
1151        assert!(eff.source_enabled(&snippet_id), "markdown keeps snippet");
1152        assert!(!eff.auto_trigger);
1153    }
1154
1155    #[test]
1156    fn effective_completion_for_language_with_no_override_allows_all_sources() {
1157        // A language without any per-language entry returns
1158        // `sources = None` -> every source contributes
1159        // (`source_enabled` is unconditionally true).
1160        let a = app_with("", 5);
1161        let eff = a.effective_completion_for("zigzig-not-a-language");
1162        let any_id = lattice_completion::SourceId::new("plugin:custom");
1163        assert!(eff.source_enabled(&any_id));
1164        assert!(eff.sources.is_none());
1165    }
1166
1167    #[test]
1168    fn reload_snippets_with_no_user_dirs_loads_builtins_only() {
1169        let mut a = app_with("", 10);
1170        // Isolate from the developer's real
1171        // `~/.config/lattice/snippets` (the default dir) so the
1172        // count is deterministic.
1173        a.editor.snippet_dirs.clear();
1174        a.do_reload_snippets();
1175        // Built-ins are always present; with no user dirs the
1176        // registry is exactly the embedded built-in set (built-ins
1177        // 2026-06-12: reload no longer wipes to empty).
1178        let builtins = lattice_snippet::load_builtins().len();
1179        assert!(builtins > 0, "built-in packs are non-empty");
1180        assert_eq!(a.editor.snippet_registry.load().len(), builtins);
1181    }
1182
1183    #[test]
1184    fn reload_snippets_walks_configured_dirs_and_keys_by_filename() {
1185        // Build a tempdir with `_global.json` (any-language)
1186        // and `rust.json` (language-specific). Reload should
1187        // route them into the right per-language slots.
1188        let dir = std::env::temp_dir().join(format!("lattice-snippet-test-{}", std::process::id()));
1189        let _ = std::fs::remove_dir_all(&dir);
1190        std::fs::create_dir_all(&dir).unwrap();
1191        std::fs::write(
1192            dir.join("_global.json"),
1193            r#"{ "anywhere": { "prefix": "any", "body": "anywhere" } }"#,
1194        )
1195        .unwrap();
1196        std::fs::write(
1197            dir.join("rust.json"),
1198            r#"{ "rust-for": { "prefix": "for", "body": "for $1 {}" } }"#,
1199        )
1200        .unwrap();
1201        let builtins = lattice_snippet::load_builtins().len();
1202        let mut a = app_with("", 10);
1203        // Isolate from the real user dir, then point only at the
1204        // tempdir so the user-pack count is deterministic.
1205        a.editor.snippet_dirs.clear();
1206        a.editor.snippet_dirs.push(dir.clone());
1207        a.do_reload_snippets();
1208        // Built-ins + the 2 user snippets from the tempdir (one
1209        // per language slot).
1210        assert_eq!(a.editor.snippet_registry.load().len(), builtins + 2);
1211        assert!(
1212            !a.editor
1213                .snippet_registry
1214                .load()
1215                .lookup("rust", "for")
1216                .is_empty()
1217        );
1218        assert!(
1219            !a.editor
1220                .snippet_registry
1221                .load()
1222                .lookup("*", "any")
1223                .is_empty()
1224        );
1225        // Global snippets are visible from any language --
1226        // `lookup` walks the per-language slot then `*`.
1227        assert!(
1228            !a.editor
1229                .snippet_registry
1230                .load()
1231                .lookup("rust", "any")
1232                .is_empty()
1233        );
1234        // A rust-only snippet should NOT be visible from a
1235        // different language slot.
1236        assert!(
1237            a.editor
1238                .snippet_registry
1239                .load()
1240                .lookup("python", "for")
1241                .is_empty()
1242        );
1243        let _ = std::fs::remove_dir_all(&dir);
1244    }
1245}