Skip to main content

lattice_host/
modes.rs

1//! Major modes for buffer kinds owned by the TUI layer.
2//!
3//! Three majors corresponding to the existing `BufferKind`
4//! variants beyond `Document`:
5//!
6//! - `help-mode` -- `:describe-*` / `:apropos` / `:keymap` views.
7//!   Read-only; markdown-mode-style content with link
8//!   navigation (`<CR>` follows a link).
9//! - `file-tree-mode` -- the file-tree navigation buffer.
10//!   Tree expansion / collapse, open-on-`<CR>`.
11//! - `oil-mode` -- editable directory listing
12//!   (oil.nvim-style). Writable; `:w` diffs the rope and
13//!   applies filesystem ops.
14//!
15//! Pure declarations in M.3.0. The actual behavior currently
16//! lives in scattered call sites in `app.rs` / `render.rs` /
17//! `input.rs`; M.3.1 routes those through the mode-id queries
18//! and M.4 unifies rendering through `ResolvedOptions`.
19//!
20//! Per `mode-architecture.md` §4.1 the TUI also hosts
21//! `command-line-mode` and `search-line-mode` for the rich
22//! minibuffer (DESIGN.md §5.9.10), but the rich minibuffer
23//! refactor isn't landed yet -- those modes are deferred to
24//! the slice that ships them.
25
26use lattice_mode::{BufferLocal, ModeId, ModeRegistry, TextMode};
27use lattice_syntax::Lang;
28
29use crate::buffers::BufferKind;
30use crate::help::{HelpAnchor, HelpLink};
31
32// ---- M.3.2.c.5: help-mode buffer-locals ----
33//
34// Three newtypes carrying the per-buffer mode-internal data
35// help-mode owns. Canonical owner of help per-buffer state per
36// M.3.2.c.5 -- the `HelpBuffer` struct no longer holds these
37// fields; the App seeds them into `buffer_locals[id]` at
38// popup-open time via `seed_help_metadata_locals`. Each
39// newtype's `OWNER_MODE` is `"help-mode"` so
40// `:describe-buffer` attributes them correctly.
41
42/// Help buffer's parsed `[label](url)` markdown links.
43#[derive(Debug, Clone)]
44pub struct HelpLinks(pub Vec<HelpLink>);
45
46impl BufferLocal for HelpLinks {
47    const NAME: &'static str = "help-mode.links";
48    const DOC: &'static str = "Parsed `[label](url)` markdown links; produced at \
49         buffer construction by the help-text parser.";
50    const OWNER_MODE: &'static str = "help-mode";
51    fn describe(&self) -> String {
52        format!("{} link(s)", self.0.len())
53    }
54}
55
56/// Help buffer's named anchors (heading slugs +
57/// introspection-recorded anchors).
58#[derive(Debug, Clone)]
59pub struct HelpAnchors(pub Vec<HelpAnchor>);
60
61impl BufferLocal for HelpAnchors {
62    const NAME: &'static str = "help-mode.anchors";
63    const DOC: &'static str = "Named scroll targets inside the help body. Heading \
64         slugs auto-generated; introspection renderers may add \
65         additional `kind:name` anchors.";
66    const OWNER_MODE: &'static str = "help-mode";
67    fn describe(&self) -> String {
68        format!("{} anchor(s)", self.0.len())
69    }
70}
71
72/// PU.1b-2a: generic per-buffer *static* highlight spans, merged into
73/// the cells-worker `DisplayMatrix` ON TOP OF (overriding, by
74/// first-match precedence) the live grammar spans on their byte
75/// ranges. Indexed by source line. Unlike [`DocumentSyntax`] (the live
76/// grammar handle, dynamic per edit) these are fixed for the buffer's
77/// content — the home for styling the grammar can't derive because the
78/// source text was transformed before parsing (help-link labels, whose
79/// `[label](target)` markup is stripped before the markdown grammar
80/// runs, so the grammar never emits a link capture). Property-derived,
81/// NOT kind-specific: any buffer carrying this local gets the merge;
82/// empty/absent is the universal default and renders byte-identically.
83#[derive(Debug, Clone, Default)]
84pub struct ExtraHighlights(pub Vec<Vec<lattice_syntax::StyledSpan>>);
85
86/// DL.3b: generic per-buffer inline virtual text, merged beside the
87/// LSP inlay hints when the pane's cells inputs are built.
88///
89/// The inlay peer of [`ExtraHighlights`], and generic for the same
90/// reason: it is the home for virtual text the *grammar and the LSP
91/// cannot produce* — a listing's leading icons today, a plugin's
92/// trailing annotations tomorrow. Written by the Editor's drain of
93/// `lattice_mode::PendingInlays`, never by a renderer.
94///
95/// Empty is the universal default and renders byte-identically.
96#[derive(Debug, Clone, Default)]
97pub struct ExtraInlays(pub Vec<crate::render_state::InlayHintRow>);
98
99impl BufferLocal for ExtraInlays {
100    const NAME: &'static str = "text-mode.extra-inlays";
101    const DOC: &'static str = "Generic per-buffer inline virtual text (leading icons, trailing \
102         annotations) merged with the LSP inlay hints in the display matrix. \
103         Published by modes through the `PendingInlays` service. Empty is the \
104         universal default and renders byte-identically.";
105    const OWNER_MODE: &'static str = "text-mode";
106    fn describe(&self) -> String {
107        format!("{} inlay row(s)", self.0.len())
108    }
109}
110
111impl BufferLocal for ExtraHighlights {
112    const NAME: &'static str = "text-mode.extra-highlights";
113    const DOC: &'static str = "Generic per-buffer static highlight spans merged on top of \
114         the live grammar spans in the display matrix, indexed by source \
115         line. The home for styling the grammar can't derive (e.g. help \
116         links, whose markup is stripped before parsing). Empty is the \
117         universal default and renders byte-identically.";
118    const OWNER_MODE: &'static str = "text-mode";
119    fn describe(&self) -> String {
120        format!("{} line(s) of extra highlights", self.0.len())
121    }
122}
123
124/// DR.3 (2026-08-12): per-buffer intra-line diff refinement — the byte
125/// ranges whose BACKGROUND differs from their row's diff tint.
126///
127/// The second axis of `span-layering.md`, narrowed from per-row to
128/// per-range. Published with [`ExtraHighlights`] by the same update and
129/// spliced by the same loop, so the two cannot drift apart when an
130/// inline expansion shifts lines.
131///
132/// Empty is the universal default and renders byte-identically.
133#[derive(Debug, Clone, Default)]
134pub struct ExtraRefinement(pub Vec<Vec<lattice_cells::RefineSpan>>);
135
136impl BufferLocal for ExtraRefinement {
137    const NAME: &'static str = "text-mode.extra-refinement";
138    const DOC: &'static str = "Per-buffer intra-line diff refinement: byte ranges whose \
139         background differs from their row's diff tint, indexed by source line. \
140         Published alongside `extra-highlights` so the two shift together.";
141    const OWNER_MODE: &'static str = "text-mode";
142    fn describe(&self) -> String {
143        format!("{} line(s) of refinement", self.0.len())
144    }
145}
146
147/// DB.4: the content block width (widest line, in cells) for a buffer that
148/// should be horizontally centred by widening its gutter. Set by the dashboard;
149/// the renderer computes `left_pad = (viewport_width - width) / 2` and adds it
150/// to the gutter, so content + cursor shift right (centred) with no text
151/// mutation — markdown / links stay intact. Absent = not centred.
152#[derive(Debug, Clone, Default)]
153pub struct CenterContentWidth(pub u32);
154
155impl BufferLocal for CenterContentWidth {
156    const NAME: &'static str = "text-mode.center-content-width";
157    const DOC: &'static str = "Content block width (cells) for gutter-based horizontal centring.";
158    const OWNER_MODE: &'static str = "text-mode";
159    fn describe(&self) -> String {
160        format!("center to block width {}", self.0)
161    }
162}
163
164// ---- file-tree-mode buffer-locals ----
165//
166// All three live in `lattice_listing::file_tree::modes` alongside their
167// declaring mode (`FileTreeMode`). Re-exported here so existing
168// `crate::modes::FileTreeRoot` etc. callsites compile without
169// change; new callers should import from `lattice_file_tree`
170// directly.
171
172pub use lattice_listing::file_tree::{FileTreeEntries, FileTreeNerdFonts, FileTreeRoot};
173
174// ---- M.3.2.c.3: oil-mode buffer-locals ----
175//
176// `OilDir` moved to `lattice_listing::oil::modes` alongside its
177// declaring mode (`OilMode`). Re-exported here so existing
178// `crate::modes::OilDir` callsites compile without change;
179// new callers should import from `lattice_oil` directly.
180
181pub use lattice_listing::oil::OilDir;
182
183// ---- M.3.2.c.4: language-mode / text-mode buffer-locals ----
184//
185// `DocumentEntry` carries four pieces of mode-internal state:
186// - `syntax: Option<SyntaxHandle>` -- per-language major mode's
187//   tree-sitter handle (incremental parse + highlight surface).
188// - `last_parsed_text_version` / `last_synced_syntax_version` --
189//   the syntax pipeline's edit-coalescing baselines.
190// - `folds: Vec<Fold>` -- universal across language majors;
191//   foundational text-mode owns it.
192//
193// These live as buffer-locals so a future language major (e.g.
194// `RustMode` registering a richer indent / fold profile) can
195// declare ownership without touching `DocumentEntry`'s shape.
196//
197// The OWNER_MODE attribution is `"text-mode"` for the foundational
198// data; `:describe-buffer` will show that. Once each language
199// mode declares richer per-language state (M.4+), we can split
200// the syntax handle's owner attribution per-language without
201// changing the local's shape.
202
203/// Per-document tree-sitter syntax handle. `Some` once a language
204/// has been detected for the buffer and a parse has been
205/// requested; `None` for `Lang::Plain` documents and for buffers
206/// that haven't been activated yet.
207#[derive(Debug, Clone)]
208pub struct DocumentSyntax(pub Option<lattice_syntax::SyntaxHandle>);
209
210impl BufferLocal for DocumentSyntax {
211    const NAME: &'static str = "text-mode.syntax";
212    const DOC: &'static str = "Per-document tree-sitter syntax handle. Drives the highlight \
213         walk + the fold computation. Owned by the document's active \
214         language major (text-mode for `Lang::Plain`, the matching \
215         language mode otherwise); the handle's runtime data lives in \
216         a worker actor referenced by this slot.";
217    const OWNER_MODE: &'static str = "text-mode";
218    fn describe(&self) -> String {
219        match &self.0 {
220            Some(_) => "attached".to_string(),
221            None => "(none)".to_string(),
222        }
223    }
224}
225
226/// Document version (the rope's monotonic edit counter) of the
227/// most recent successful parse. The renderer's syntax walk
228/// short-circuits when `text_version == last_parsed_text_version`;
229/// the reparse seam treats inequality as "edits to drain into the
230/// worker" and fires an incremental reparse.
231#[derive(Debug, Clone, Copy)]
232pub struct DocumentLastParsedTextVersion(pub u64);
233
234impl BufferLocal for DocumentLastParsedTextVersion {
235    const NAME: &'static str = "text-mode.last-parsed-text-version";
236    const DOC: &'static str = "Document version (rope monotonic) of the most recent \
237         successful parse. Cheap idempotency check on the highlight \
238         hot path: equal version means the cached spans are still \
239         current; unequal triggers a reparse.";
240    const OWNER_MODE: &'static str = "text-mode";
241    fn describe(&self) -> String {
242        format!("v{}", self.0)
243    }
244}
245
246/// Document version that was the baseline for the last syntax-
247/// worker request fired for this buffer. Sent as `from_version`
248/// on each request so the worker can verify edits apply to the
249/// expected tree baseline before running the incremental
250/// `tree.edit()`. Independent of `last_parsed_text_version`
251/// because the worker may still be in flight when the next
252/// request fires.
253#[derive(Debug, Clone, Copy)]
254pub struct DocumentLastSyncedSyntaxVersion(pub u64);
255
256impl BufferLocal for DocumentLastSyncedSyntaxVersion {
257    const NAME: &'static str = "text-mode.last-synced-syntax-version";
258    const DOC: &'static str = "Document version baseline most recently sent to the syntax \
259         worker. Worker uses it as `from_version` to verify edits \
260         apply against the expected tree before running tree.edit().";
261    const OWNER_MODE: &'static str = "text-mode";
262    fn describe(&self) -> String {
263        format!("v{}", self.0)
264    }
265}
266
267/// Per-document fold list. Empty means "not yet computed for this
268/// buffer." First-activation seeds from the active foldmethod;
269/// subsequent re-activations restore the user's open / closed
270/// state. Universal across language majors -- the foundational
271/// text-mode owns the data shape; per-language fold queries are
272/// a presentation-layer concern read through the syntax local.
273#[derive(Debug, Clone, Default)]
274pub struct DocumentFolds(pub Vec<lattice_core::Fold>);
275
276impl BufferLocal for DocumentFolds {
277    const NAME: &'static str = "text-mode.folds";
278    const DOC: &'static str = "Per-document fold list. Empty until the activation hook \
279         seeds from the buffer's foldmethod; thereafter holds the \
280         user's open / closed state across buffer-switch \
281         round-trips.";
282    const OWNER_MODE: &'static str = "text-mode";
283    fn describe(&self) -> String {
284        format!("{} fold(s)", self.0.len())
285    }
286}
287
288// M.4 follow-up: HelpMode / HoverMode / FileTreeMode / OilMode
289// migrated to `lattice-mode::modes` (one file per mode). The
290// dep-inversion that made this possible: layer-input types
291// (`OptionOverride{,Set}`, `OverridePriority`) live in
292// `lattice-config`, `lattice-mode` depends on `lattice-config`,
293// and `Document::modes` was removed from `lattice-core` so the
294// cycle through `lattice-core -> lattice-mode` is gone.
295//
296// Re-exported here so existing `crate::modes::HelpMode` etc.
297// imports keep working during the transition.
298pub use lattice_mode::{HelpMode, HoverMode};
299// Feature-crate-owned modes (moved per the mode-architecture
300// convention; see each feature crate's `modes.rs`).
301// Re-exported here so `crate::modes::*` callsites keep
302// compiling; new code imports from the feature crate.
303pub use lattice_listing::file_tree::FileTreeMode;
304pub use lattice_listing::oil::OilMode;
305
306/// Resolve the default major-mode id for a [`BufferKind`] via
307/// the registry's kind index (H.2).
308///
309/// `Document` returns `None` because the mode is determined by
310/// language detection (see
311/// [`lattice_syntax::major_mode_id_for_lang`]); when the language
312/// detection returns `Lang::Plain` the caller falls back further
313/// to `text-mode`.
314///
315/// All other kinds dispatch through
316/// [`ModeRegistry::find_major_for_kind`]: each major mode
317/// (`FileTreeMode`, `OilMode`, `MarkdownMode` for Help,
318/// `MessagesMode`, `TerminalMode`, future plugin-defined majors)
319/// declares its target kind via [`Mode::target_buffer_kind`] and
320/// the registry indexes them at register-time. Adding a new
321/// kind-bound major requires zero host-side hand edits — register
322/// the mode and the index picks it up.
323pub fn major_mode_id_for_buffer_kind(registry: &ModeRegistry, kind: BufferKind) -> Option<ModeId> {
324    if kind == BufferKind::Document {
325        // Document dispatches via `Lang` detection; the kind index
326        // does not bind to `Document`.
327        return None;
328    }
329    registry.find_major_for_kind(kind)
330}
331
332/// M.4 (Option B): minor modes the App activates alongside the
333/// major resolved by [`major_mode_id_for_buffer_kind`]. Returns
334/// the per-kind minor that gives the buffer its full identity.
335/// Help buffers get `help-mode` (read-only + link/anchor
336/// follow); other kinds currently get nothing -- their majors
337/// already carry their full contribution set.
338pub fn default_minor_mode_id_for_buffer_kind(kind: BufferKind) -> Option<ModeId> {
339    match kind {
340        BufferKind::Help => Some(HelpMode::mode_id()),
341        // Dashboard's major (`dashboard-mode`) carries its own full
342        // contribution set (read-only, gutterless); no default minor.
343        BufferKind::Document
344        | BufferKind::FileTree
345        | BufferKind::Oil
346        | BufferKind::Terminal
347        | BufferKind::Messages
348        | BufferKind::Multibuffer
349        | BufferKind::Dashboard => None,
350    }
351}
352
353/// CSM.K1 (insert-completion.md §12): additional minor modes
354/// the App activates alongside the buffer's major + the kind's
355/// `default_minor`. Today: `completion-mode` on writable kinds
356/// (Document) so `<C-Space>` opens the popup; read-only kinds
357/// (Help, FileTree, Oil) don't activate it, so the trigger
358/// chord is a silent no-op there.
359pub fn auto_activated_minors_for_buffer_kind(kind: BufferKind) -> Vec<ModeId> {
360    match kind {
361        BufferKind::Document => vec![
362            lattice_mode::CompletionMode::mode_id(),
363            // CSM.4: buffer-words-mode auto-activates on every
364            // Document; contributes the buffer-words source to
365            // the popup's `ActiveCompletionSources` cache.
366            lattice_mode::BufferWordsMode::mode_id(),
367            // SN.3a: `snippet-completion-mode` is no longer
368            // activated language-blind here. `snippet-mode` (the
369            // language-aware gate) `implies` it and auto-activates
370            // via the `MajorEntered` resolver (`ActivationPolicy`),
371            // so the source rides the gate. With the default
372            // `Global` policy this is behavior-preserving (the
373            // source still reaches every Document), but SN.3b's
374            // config can now restrict it per-language.
375            // CSM.6: tree-sitter-completion-mode owns the
376            // local-symbol source; produce-time is cheap (host
377            // pre-walks `collect_symbols()` once per populate
378            // and threads via `ctx.tree_sitter_symbols`).
379            lattice_syntax::TreeSitterCompletionMode::mode_id(),
380            // CSM.7: path-completion-mode owns the path source;
381            // self-suppresses outside string scopes via
382            // `ctx.path_context`.
383            lattice_mode::PathCompletionMode::mode_id(),
384        ],
385        // Read-only kinds: nothing to add. (`help-mode` /
386        // `oil-mode` / `file-tree-mode` are already activated
387        // via their major's default-minor path.)
388        BufferKind::Help
389        | BufferKind::FileTree
390        | BufferKind::Oil
391        | BufferKind::Terminal
392        | BufferKind::Messages
393        | BufferKind::Multibuffer
394        | BufferKind::Dashboard => Vec::new(),
395    }
396}
397
398/// M.4 follow-up: the per-kind modes (HelpMode, HoverMode,
399/// FileTreeMode, OilMode) moved to `lattice_mode::modes` and
400/// register through `lattice_mode::modes::register_foundation_modes`
401/// alongside `TextMode`. This shim is kept as a no-op so existing
402/// `register_buffer_kind_modes` callers don't break; the App's
403/// boot path now calls `register_foundation_modes` directly and
404/// drops this helper in a follow-up.
405pub fn register_buffer_kind_modes(_registry: &mut ModeRegistry) {
406    // intentionally empty
407}
408
409/// Resolve the major-mode id a buffer should activate based on
410/// its kind + (for `Document` kinds) detected language.
411///
412/// H.2 (2026-05-31): rewritten to consult the [`ModeRegistry`]'s
413/// kind index ([`major_mode_id_for_buffer_kind`]) for non-Document
414/// kinds, then [`lattice_syntax::major_mode_id_for_lang`] for
415/// Document-kind buffers, finally falling back to [`TextMode`]
416/// when neither layer matches (Document + `Lang::Plain`, or an
417/// unknown / unbound kind).
418///
419/// OM.1 (2026-08-24): a third layer between the two — the registry's
420/// **language index** ([`ModeRegistry::find_major_for_lang`]). It is
421/// what lets a *plugin-contributed* language have a major at all:
422/// `major_mode_id_for_lang` is a hand-written `match` over the `Lang`
423/// enum and returns `None` for `Lang::Plugin(_)` by design, since the
424/// host does not know the language exists until a plugin says so.
425/// Before this layer a `.org` file landed in `text-mode`.
426///
427/// The built-in table is consulted **first**, so a plugin cannot take
428/// `rust-mode`'s language out from under it. When the built-in majors
429/// eventually migrate onto `Mode::target_language` the table shrinks,
430/// the index grows, and the order between them stops mattering.
431pub fn resolve_major_mode(
432    registry: &ModeRegistry,
433    kind: BufferKind,
434    lang: Lang,
435    path: Option<&std::path::Path>,
436) -> ModeId {
437    if let Some(id) = major_mode_id_for_buffer_kind(registry, kind) {
438        return id;
439    }
440    // A major that PRESENTS this file type wins over any language mapping:
441    // the file was never loaded as text, so a language claim on it would be a
442    // claim about content nobody read. `image-mode` is the first of these.
443    if let Some(id) = path.and_then(|p| registry.presenting_major_for_path(p)) {
444        return id;
445    }
446    // Document kind (or any kind with no registered major): pick by
447    // language — the built-in table, then any registered claim, then
448    // text-mode.
449    if let Some(id) = lattice_syntax::major_mode_id_for_lang(lang) {
450        return id;
451    }
452    // `Lang::name()` is the canonical registry key for every arm,
453    // and for `Lang::Plugin` it IS the interned identity — so this
454    // needs no plugin-specific branch.
455    if let Some(id) = registry.find_major_for_lang(lang.name()) {
456        return id;
457    }
458    TextMode::mode_id()
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    /// OM.2b: `keymap.leader`'s default is written as a literal in
466    /// `lattice-config` because that crate does not depend on
467    /// `lattice-keymap`. This is the pin that stops the two drifting — both
468    /// are in scope here.
469    #[test]
470    fn the_leader_option_default_matches_the_keymap_default() {
471        use lattice_config::OptionDecl;
472        assert_eq!(
473            lattice_config::core_options::KeymapLeader::default_value(),
474            lattice_keymap::DEFAULT_LEADER,
475            "keymap.leader's default and DEFAULT_LEADER must not drift"
476        );
477    }
478
479    #[test]
480    fn each_buffer_kind_mode_has_distinct_id() {
481        let ids = [
482            HelpMode::mode_id(),
483            FileTreeMode::mode_id(),
484            OilMode::mode_id(),
485        ];
486        for (i, a) in ids.iter().enumerate() {
487            for b in &ids[i + 1..] {
488                assert_ne!(a, b);
489            }
490        }
491    }
492
493    #[test]
494    fn buffer_kind_to_mode_id_table() {
495        // H.2: lookups now go through the registry's kind index.
496        // Foundation + feature-crate registration helpers must run
497        // first so the index is populated.
498        let mut registry = ModeRegistry::new();
499        lattice_mode::register_foundation_modes(&mut registry);
500        lattice_syntax::register_language_modes(&mut registry);
501        lattice_listing::oil::register_oil_modes(&mut registry);
502        lattice_listing::file_tree::register_file_tree_modes(&mut registry);
503        lattice_listing::listing_mode::register_listing_modes(&mut registry);
504        lattice_terminal::register_terminal_modes(&mut registry);
505
506        assert_eq!(
507            major_mode_id_for_buffer_kind(&registry, BufferKind::Document),
508            None
509        );
510        // M.4 (Option B): help buffers run markdown-mode as their
511        // major; help-mode is layered on as a minor by the App's
512        // activation path (see `default_minor_mode_id_for_buffer_kind`).
513        assert_eq!(
514            major_mode_id_for_buffer_kind(&registry, BufferKind::Help),
515            Some(lattice_syntax::MarkdownMode::mode_id())
516        );
517        assert_eq!(
518            default_minor_mode_id_for_buffer_kind(BufferKind::Help),
519            Some(HelpMode::mode_id())
520        );
521        assert_eq!(
522            major_mode_id_for_buffer_kind(&registry, BufferKind::FileTree),
523            Some(FileTreeMode::mode_id())
524        );
525        assert_eq!(
526            default_minor_mode_id_for_buffer_kind(BufferKind::FileTree),
527            None
528        );
529        assert_eq!(
530            major_mode_id_for_buffer_kind(&registry, BufferKind::Oil),
531            Some(OilMode::mode_id())
532        );
533        assert_eq!(
534            major_mode_id_for_buffer_kind(&registry, BufferKind::Messages),
535            Some(lattice_mode::MessagesMode::mode_id())
536        );
537        assert_eq!(
538            major_mode_id_for_buffer_kind(&registry, BufferKind::Terminal),
539            Some(lattice_terminal::TerminalMode::mode_id())
540        );
541        // M.2.b.2 will register MultibufferMode through a
542        // `lattice_multibuffer::register_multibuffer_modes` helper
543        // and this assertion will flip to Some(...). Today the
544        // multibuffer crate has no Mode, so the kind index is
545        // empty for that slot.
546        assert_eq!(
547            major_mode_id_for_buffer_kind(&registry, BufferKind::Multibuffer),
548            None
549        );
550    }
551
552    #[test]
553    fn foundation_register_includes_per_kind_modes() {
554        // M.4 follow-up: per-kind modes register through
555        // `lattice_mode::modes::register_foundation_modes`
556        // *and* the feature-crate-owned mode registration
557        // helpers (`lattice_listing::oil::register_oil_modes`, etc.).
558        // App boot calls all of them; the test mirrors that
559        // shape so the assertion set matches what an actual
560        // boot produces.
561        let mut registry = ModeRegistry::new();
562        lattice_mode::register_foundation_modes(&mut registry);
563        lattice_listing::oil::register_oil_modes(&mut registry);
564        lattice_listing::file_tree::register_file_tree_modes(&mut registry);
565        lattice_listing::listing_mode::register_listing_modes(&mut registry);
566        assert!(registry.is_registered(HelpMode::mode_id()));
567        assert!(registry.is_registered(FileTreeMode::mode_id()));
568        assert!(registry.is_registered(OilMode::mode_id()));
569        assert!(registry.is_registered(HoverMode::mode_id()));
570    }
571
572    fn populated_registry() -> ModeRegistry {
573        let mut r = ModeRegistry::new();
574        lattice_mode::register_foundation_modes(&mut r);
575        lattice_syntax::register_language_modes(&mut r);
576        lattice_listing::oil::register_oil_modes(&mut r);
577        lattice_listing::file_tree::register_file_tree_modes(&mut r);
578        lattice_listing::listing_mode::register_listing_modes(&mut r);
579        lattice_terminal::register_terminal_modes(&mut r);
580        r
581    }
582
583    #[test]
584    fn resolve_major_mode_combines_kind_and_lang() {
585        // Help / FileTree / Oil ignore Lang -- their kind alone
586        // determines the major. Help maps to markdown-mode
587        // (Option B); help-mode is the minor.
588        let registry = populated_registry();
589        assert_eq!(
590            resolve_major_mode(&registry, BufferKind::Help, Lang::Rust, None),
591            lattice_syntax::MarkdownMode::mode_id()
592        );
593        assert_eq!(
594            resolve_major_mode(&registry, BufferKind::FileTree, Lang::Plain, None),
595            FileTreeMode::mode_id()
596        );
597        assert_eq!(
598            resolve_major_mode(&registry, BufferKind::Oil, Lang::Markdown, None),
599            OilMode::mode_id()
600        );
601    }
602
603    #[test]
604    fn resolve_major_mode_for_document_picks_by_lang() {
605        let registry = populated_registry();
606        assert_eq!(
607            resolve_major_mode(&registry, BufferKind::Document, Lang::Rust, None),
608            lattice_syntax::RustMode::mode_id()
609        );
610        assert_eq!(
611            resolve_major_mode(&registry, BufferKind::Document, Lang::Markdown, None),
612            lattice_syntax::MarkdownMode::mode_id()
613        );
614    }
615
616    #[test]
617    fn resolve_major_mode_falls_back_to_text_mode() {
618        // Document + Plain ⇒ text-mode (foundation catch-all).
619        let registry = populated_registry();
620        assert_eq!(
621            resolve_major_mode(&registry, BufferKind::Document, Lang::Plain, None),
622            TextMode::mode_id()
623        );
624    }
625
626    // ── OM.1: plugin languages resolve through the registry index ──
627
628    /// A stand-in for the major a plugin declares through the `modes`
629    /// seam — nothing here is org-specific except the name it claims.
630    #[derive(Debug)]
631    struct PluginLangMode {
632        id: &'static str,
633        lang: &'static str,
634        kind: lattice_mode::ModeKind,
635    }
636
637    impl lattice_mode::Mode for PluginLangMode {
638        type Guard = ();
639        fn id(&self) -> ModeId {
640            ModeId::new(self.id)
641        }
642        fn kind(&self) -> lattice_mode::ModeKind {
643            self.kind
644        }
645        fn target_language(&self) -> Option<&str> {
646            Some(self.lang)
647        }
648        fn on_activate(
649            &self,
650            _ctx: lattice_mode::ModeContext,
651        ) -> lattice_mode::LifecycleFuture<'_, Self::Guard> {
652            Box::pin(async { Ok(()) })
653        }
654    }
655
656    fn org_lang() -> Lang {
657        Lang::Plugin(lattice_syntax::LanguageName::intern("org"))
658    }
659
660    #[test]
661    fn resolve_major_mode_uses_the_language_index_for_a_plugin_language() {
662        // The headline of the slice. `major_mode_id_for_lang` returns
663        // `None` for `Lang::Plugin(_)` by design, so before the index
664        // this landed in text-mode.
665        let mut registry = populated_registry();
666        registry
667            .register(PluginLangMode {
668                id: "org-mode",
669                lang: "org",
670                kind: lattice_mode::ModeKind::Major,
671            })
672            .unwrap();
673        assert_eq!(
674            resolve_major_mode(&registry, BufferKind::Document, org_lang(), None),
675            ModeId::new("org-mode")
676        );
677    }
678
679    #[test]
680    fn a_plugin_language_with_no_claimed_major_still_falls_back_to_text_mode() {
681        // Graceful degradation: the `language` seam can load without the
682        // `modes` seam (or its mode can be rejected), and a highlighted
683        // org buffer with no org-mode is a perfectly good outcome.
684        let registry = populated_registry();
685        assert_eq!(
686            resolve_major_mode(&registry, BufferKind::Document, org_lang(), None),
687            TextMode::mode_id()
688        );
689    }
690
691    #[test]
692    fn the_builtin_table_wins_over_a_plugin_claim() {
693        // A plugin claiming "rust" must not take rust-mode's language
694        // out from under it: the hand-written table is consulted first.
695        let mut registry = populated_registry();
696        registry
697            .register(PluginLangMode {
698                id: "impostor-mode",
699                lang: "rust",
700                kind: lattice_mode::ModeKind::Major,
701            })
702            .unwrap();
703        assert_eq!(
704            resolve_major_mode(&registry, BufferKind::Document, Lang::Rust, None),
705            lattice_syntax::RustMode::mode_id()
706        );
707    }
708
709    #[test]
710    fn a_kind_bound_major_still_wins_over_the_language_index() {
711        // Layer order is unchanged: kind first. A Help buffer is
712        // markdown-mode even if its detected language claims otherwise.
713        let mut registry = populated_registry();
714        registry
715            .register(PluginLangMode {
716                id: "org-mode",
717                lang: "org",
718                kind: lattice_mode::ModeKind::Major,
719            })
720            .unwrap();
721        assert_eq!(
722            resolve_major_mode(&registry, BufferKind::Help, org_lang(), None),
723            lattice_syntax::MarkdownMode::mode_id()
724        );
725    }
726
727    #[test]
728    fn a_minor_claiming_the_language_does_not_become_the_major() {
729        let mut registry = populated_registry();
730        registry
731            .register(PluginLangMode {
732                id: "org-todo-mode",
733                lang: "org",
734                kind: lattice_mode::ModeKind::Minor,
735            })
736            .unwrap();
737        assert_eq!(
738            resolve_major_mode(&registry, BufferKind::Document, org_lang(), None),
739            TextMode::mode_id()
740        );
741    }
742
743    #[test]
744    fn resolve_major_mode_handles_empty_registry() {
745        // Brand-new registry has no kind index entries — every
746        // non-Document kind falls through to TextMode (the
747        // language-detection catch-all on `Lang::Plain`).
748        let registry = ModeRegistry::new();
749        assert_eq!(
750            resolve_major_mode(&registry, BufferKind::FileTree, Lang::Plain, None),
751            TextMode::mode_id()
752        );
753    }
754}
755
756// MG.2: PendingSyntheticHighlights lives in lattice-mode now
757// (crates/lattice-mode/src/pending_synthetic_highlights.rs).
758// No import needed here — it's accessed via lattice_mode::PendingSyntheticHighlights
759// in dispatch.rs and editor_boot.rs.