diff --git a/builtin/runtime/syntax.lua b/builtin/runtime/syntax.lua index 18164dc..2d33368 100644 --- a/builtin/runtime/syntax.lua +++ b/builtin/runtime/syntax.lua @@ -228,7 +228,7 @@ local function resolve_active_language(buf) return pmacs.parse.language_from_shebang(buf) end -local function attach_for_active_buffer() +local function attach_for_active_buffer(initialize_mode) local buf = pmacs.window.buffer() if not buf then return end local key = tostring(buf) @@ -245,6 +245,14 @@ local function attach_for_active_buffer() -- never gives a wrong-grammar tree. local lang = pmacs.parse._has_view(buf) and parse_lang_by_buffer[key] or resolve_active_language(buf) + -- The detected language is also the initial major-mode name. Do this + -- before grammar gating: a language supplied only by an LSP filetype or + -- shebang mapping is still a valid mode even when no parser is bundled. + -- Only after-load initializes it; after-switch must preserve explicit + -- overrides and explicit nil clears. + if initialize_mode and lang and pmacs.buffer.major_mode(buf) == nil then + pmacs.buffer.set_major_mode(buf, lang) + end if not lang or not pmacs.parse._has_language(lang) then return end pmacs.parse._dispatch(buf, lang) -- T M4.3: install the syntax-highlight overlay for this buffer. @@ -266,7 +274,7 @@ end pmacs.hook.add("buffer.after-load", function() -- Best-effort: a missing grammar / re-entry / stale buffer -- mustn't poison the rest of the after-load chain. - local ok, err = pcall(attach_for_active_buffer) + local ok, err = pcall(function() attach_for_active_buffer(true) end) if not ok and pmacs.error then pmacs.error("syntax.after-load: " .. tostring(err)) end @@ -284,13 +292,29 @@ pmacs.hook.add("buffer.after-switch", function() local buf = pmacs.window.buffer() if not buf then return end highlighted_buffers[tostring(buf)] = nil - attach_for_active_buffer() + attach_for_active_buffer(false) end) if not ok and pmacs.error then pmacs.error("syntax.after-switch: " .. tostring(err)) end end) +-- Major mode is window-local presentation state because each split may show +-- a different buffer. The provider therefore reads ctx.buffer rather than +-- the focused buffer. Empty text omits the segment without tripping the +-- statusline failure latch. +pmacs.statusline.register { + name = "mode", + side = "left", + priority = 0, + face = "ui.modeline", + fn = function(ctx) + local mode = pmacs.buffer.major_mode(ctx.buffer) + if mode == nil then return "" end + return "(" .. mode .. ")" + end, +} + local function reparse_active_buffer_after_edit() local buf = pmacs.window.buffer() if not buf then return end diff --git a/src/buffer.rs b/src/buffer.rs index c8fbb0a..304918b 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -159,6 +159,8 @@ pub struct Buffer { id: BufferId, rope: Rope, name: String, + /// The buffer's single active major mode, if one has been selected. + major_mode: Option, is_modified: bool, /// When set, every content mutation is rejected before touching the /// rope, CRDT, history, revision, modified bit, marks, or views. @@ -245,6 +247,7 @@ impl Buffer { id, rope, name: name.into(), + major_mode: None, is_modified: false, read_only: false, revision: 0, @@ -451,6 +454,20 @@ impl Buffer { self.name = name.into(); } + /// This buffer's active major mode, if any. + /// + /// The returned name borrows the buffer-owned mode string so key + /// dispatch can resolve mode bindings without cloning on its hot path. + #[must_use] + pub fn major_mode(&self) -> Option<&str> { + self.major_mode.as_deref() + } + + /// Replace this buffer's active major mode, or clear it with `None`. + pub fn set_major_mode(&mut self, major_mode: Option) { + self.major_mode = major_mode; + } + /// Whether the buffer has been modified since the last save / load. #[must_use] pub fn is_modified(&self) -> bool { @@ -1869,6 +1886,18 @@ mod tests { out } + #[test] + fn major_mode_is_buffer_owned_and_replaceable() { + let mut buf = Buffer::new(BufferId::next(), "*mode-test*"); + assert_eq!(buf.major_mode(), None); + + buf.set_major_mode(Some("rust".to_owned())); + assert_eq!(buf.major_mode(), Some("rust")); + + buf.set_major_mode(None); + assert_eq!(buf.major_mode(), None); + } + dual_mode_test!( read_only_rejects_direct_skip_history_mutations, |make, make_bytes| { diff --git a/src/editor.rs b/src/editor.rs index a8590ae..dc7deea 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -797,16 +797,21 @@ impl EditorState { return; } - // Buffer-scope keybindings need the active buffer id passed - // through the dispatcher (otherwise `keymap_stack::resolve` - // skips the buffer-local map entirely and every "scope = - // buffer" binding falls through to global). The id is read - // outside the keymap borrow so a single-buffer focus check - // doesn't collide with the stack lookup below. - let active_buffer = Some(self.core.borrow().active_buffer_id()); + // Buffer- and mode-scope keybindings resolve against the active + // buffer. Keep its mode borrowed from the registry only while the + // pure keymap lookup runs: `Option::as_slice` provides the required + // zero-or-one borrowed slice without allocating or cloning. Both + // RefCell borrows end with this block, before any Lua command runs. + let active_buffer = self.core.borrow().active_buffer_id(); let action = { + let registry = self.lua_host.registry().borrow(); + let active_mode = registry + .get(active_buffer) + .ok() + .and_then(|buffer| buffer.major_mode()); let stack = self.lua_host.keymaps().borrow(); - self.dispatcher.dispatch(chord, &stack, active_buffer, &[]) + self.dispatcher + .dispatch(chord, &stack, Some(active_buffer), active_mode.as_slice()) }; // Snapshot the active buffer's edit revision before the command @@ -3722,6 +3727,34 @@ mod tests { } } + #[test] + fn dispatch_uses_active_buffer_major_mode_and_releases_borrows() { + let mut s = fresh_with(b""); + let buffer_id = s.core.borrow().active_buffer_id(); + s.lua_host + .registry() + .borrow_mut() + .get_mut(buffer_id) + .unwrap() + .set_major_mode(Some("dispatch-test".to_owned())); + s.lua_host + .keymaps() + .borrow_mut() + .bind_mode( + "dispatch-test", + &crate::key::parse_sequence("C-b").unwrap(), + "editor.list-buffers", + crate::command::SourceLocation::default(), + ) + .unwrap(); + + // `editor.list-buffers` mutably borrows the buffer registry. Reaching + // the resulting buffer therefore proves dispatch released both its + // registry and keymap borrows before invoking the mode-bound command. + s.dispatch_key(FrontendId::LOCAL, ctrl('b')); + assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*"); + } + #[test] fn cx_cb_invokes_list_buffers() { // Regression for the user-reported "C-x C-b stalls" bug. After diff --git a/src/help.rs b/src/help.rs index c3688b7..b5b0985 100644 --- a/src/help.rs +++ b/src/help.rs @@ -13,6 +13,7 @@ //! * `[command: cursor.left]` --- navigate to that command's help. //! * `[key: C-x C-s]` --- describe the chord. //! * `[key: s @buffer:3]` --- describe a buffer-local chord. +//! * `[key: g @mode:rust]` --- describe a mode-scoped chord. //! * `[buffer: *errors*]` --- describe a buffer by name. //! * `[mode: normal]`, `[hook: buffer.before-save]`, `[view: *help*]`. //! @@ -84,21 +85,20 @@ pub fn render_command( /// Render help for a chord sequence. Returns the help buffer id if /// the sequence resolves to a binding, [`None`] otherwise. /// -/// `active_buffer` is the buffer scope to consult when resolving -/// the chord sequence. Pass `Some(id)` to surface buffer-local -/// bindings (matching what `dispatch_key` would see) and `None` -/// for global-only resolution. Buffer-scope keys (e.g., -/// `pmacs-magit.stage` bound to `s` on the magit buffer) are -/// invisible without this, which is the M8.7 describe-key gap. +/// `active_buffer` and `active_modes` are the exact scope context to +/// consult when resolving the chord sequence. Pass the active buffer +/// and its zero-or-one major-mode slice to match dispatch, or no +/// context for global-only resolution. pub fn render_key( registry: &mut BufferRegistry, commands: &CommandRegistry, keymaps: &KeymapStack, active_buffer: Option, + active_modes: &[&str], sequence: &str, ) -> RenderResult { let chords = parse_sequence(sequence).ok()?; - let resolution = keymaps.resolve(&chords, active_buffer, &[]); + let resolution = keymaps.resolve(&chords, active_buffer, active_modes); let StackResolution::Bound(rb) = resolution else { return None; }; @@ -150,7 +150,7 @@ pub fn render_mode( let mut text = String::new(); let _ = writeln!(text, "Mode: {name}"); let _ = writeln!(text); - write_mode_bindings(&mut text, map); + write_mode_bindings(&mut text, name, map); Some(replace_help_buffer(registry, &text)) } @@ -245,6 +245,16 @@ fn write_command_bindings( scope.render() ); } + Scope::Mode(name) => { + let encoded_name = encode_mode_target(name); + let _ = writeln!( + out, + " [key: {} @mode:{}] ({})", + display_sequence(seq), + encoded_name, + scope.render() + ); + } _ => { let _ = writeln!( out, @@ -258,33 +268,84 @@ fn write_command_bindings( } } -fn parse_key_target(registry: &BufferRegistry, target: &str) -> Option<(String, Option)> { - let Some((sequence, raw)) = target.rsplit_once(" @buffer:") else { - return Some((target.to_owned(), None)); - }; - let Ok(raw) = raw.trim().parse::() else { - return None; - }; - let id = BufferId::from_raw(raw); - if registry.contains(id) { - Some((sequence.trim().to_owned(), Some(id))) - } else { - None - } +fn is_mode_target_unreserved(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b'-') } -fn write_mode_bindings(out: &mut String, map: &Keymap) { +fn encode_mode_target(mode: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + + let mut encoded = String::with_capacity(mode.len()); + for byte in mode.bytes() { + if is_mode_target_unreserved(byte) { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0F)])); + } + } + encoded +} + +fn decode_mode_target(encoded: &str) -> Option { + let encoded = encoded.as_bytes(); + let mut decoded = Vec::with_capacity(encoded.len()); + let mut index = 0; + while index < encoded.len() { + let byte = encoded[index]; + if byte == b'%' { + let high = *encoded.get(index + 1)?; + let low = *encoded.get(index + 2)?; + let high = char::from(high).to_digit(16)?; + let low = char::from(low).to_digit(16)?; + decoded.push(u8::try_from((high << 4) | low).ok()?); + index += 3; + } else { + if !is_mode_target_unreserved(byte) { + return None; + } + decoded.push(byte); + index += 1; + } + } + String::from_utf8(decoded).ok() +} + +fn parse_key_target( + registry: &BufferRegistry, + target: &str, +) -> Option<(String, Option, Option)> { + if let Some((sequence, raw)) = target.rsplit_once(" @buffer:") { + let raw = raw.trim().parse::().ok()?; + let id = BufferId::from_raw(raw); + return registry + .contains(id) + .then(|| (sequence.trim().to_owned(), Some(id), None)); + } + + if let Some((sequence, encoded_mode)) = target.rsplit_once(" @mode:") { + let mode = decode_mode_target(encoded_mode)?; + return Some((sequence.trim().to_owned(), None, Some(mode))); + } + + Some((target.to_owned(), None, None)) +} + +fn write_mode_bindings(out: &mut String, mode: &str, map: &Keymap) { let entries: Vec<_> = map.iter().collect(); if entries.is_empty() { let _ = writeln!(out, "(empty mode keymap)"); return; } let _ = writeln!(out, "Bindings:"); + let encoded_mode = encode_mode_target(mode); for (seq, binding) in entries { let _ = writeln!( out, - " [key: {}] -> [command: {}]", + " [key: {} @mode:{}] -> [command: {}]", display_sequence(&seq), + encoded_mode, binding.command ); } @@ -430,8 +491,17 @@ pub fn follow_link_at( match link.kind.as_str() { "command" => render_command(registry, commands, keymaps, &link.target), "key" => { - let (sequence, active_buffer) = parse_key_target(registry, &link.target)?; - render_key(registry, commands, keymaps, active_buffer, &sequence) + let (sequence, active_buffer, mode) = parse_key_target(registry, &link.target)?; + let mode = mode.as_deref(); + let active_modes = mode.as_slice(); + render_key( + registry, + commands, + keymaps, + active_buffer, + active_modes, + &sequence, + ) } "buffer" => { let id = registry.find_by_name(&link.target)?; @@ -542,7 +612,7 @@ mod tests { }, ) .unwrap(); - let (id, _) = render_key(&mut reg, &cmds, &kms, None, "C-x C-s").unwrap(); + let (id, _) = render_key(&mut reg, &cmds, &kms, None, &[], "C-x C-s").unwrap(); let body = read_buffer_text(reg.get(id).unwrap()); assert!(body.contains("Key: C-x C-s")); assert!(body.contains("[command: save]")); @@ -554,7 +624,38 @@ mod tests { let mut reg = BufferRegistry::new(); let cmds = CommandRegistry::new(); let kms = KeymapStack::new(); - assert!(render_key(&mut reg, &cmds, &kms, None, "C-q").is_none()); + assert!(render_key(&mut reg, &cmds, &kms, None, &[], "C-q").is_none()); + } + + #[test] + fn render_key_uses_explicit_mode_context_before_global() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + cmds.define(make_command(&lua, "rust.save", "Rust save.")) + .unwrap(); + cmds.define(make_command(&lua, "global.save", "Global save.")) + .unwrap(); + kms.bind_global( + &parse_sequence("C-s").unwrap(), + "global.save", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_mode( + "rust", + &parse_sequence("C-s").unwrap(), + "rust.save", + SourceLocation::default(), + ) + .unwrap(); + + render_key(&mut reg, &cmds, &kms, None, &["rust"], "C-s").unwrap(); + let body = read_help(®); + assert!(body.contains("Scope: mode:rust"), "{body}"); + assert!(body.contains("[command: rust.save]"), "{body}"); + assert!(!body.contains("[command: global.save]"), "{body}"); } #[test] @@ -643,7 +744,7 @@ mod tests { let _ = render_mode(&mut reg, &kms, "demo").unwrap(); let body = read_help(®); assert!(body.contains("Mode: demo")); - assert!(body.contains("[key: C-x]")); + assert!(body.contains("[key: C-x @mode:demo]")); assert!(body.contains("[command: x]")); } @@ -678,6 +779,26 @@ mod tests { assert_eq!(span.target, "C-x C-s"); } + #[test] + fn mode_key_target_codec_is_strict_and_round_trips_utf8() { + for mode in ["rust", "", " rust ", "]", "%", "雪", "x @buffer:1"] { + let encoded = encode_mode_target(mode); + assert_eq!(decode_mode_target(&encoded).as_deref(), Some(mode)); + } + assert_eq!(encode_mode_target("rust"), "rust"); + for malformed in ["%", "%0", "%GG", "%FF", "raw space", "雪"] { + assert!( + decode_mode_target(malformed).is_none(), + "accepted malformed mode target {malformed:?}" + ); + } + + let reg = BufferRegistry::new(); + let (_, _, mode) = parse_key_target(®, "s @mode:").unwrap(); + assert_eq!(mode.as_deref(), Some("")); + assert!(parse_key_target(®, "s @mode:%FF").is_none()); + } + #[test] fn link_at_off_link_returns_none() { let text = "Plain text with no [command: foo] here.\n"; @@ -752,4 +873,82 @@ mod tests { assert!(body.contains("Scope: buffer"), "{body}"); assert!(body.contains("[command: pmacs-magit.stage]"), "{body}"); } + + #[test] + fn follow_mode_key_link_preserves_mode_after_help_buffer_activation() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + let hooks = HookRegistry::new(); + cmds.define(make_command(&lua, "rust.action", "Mode action.")) + .unwrap(); + cmds.define(make_command(&lua, "global.action", "Global fallback.")) + .unwrap(); + kms.bind_mode( + "rust", + &parse_sequence("s").unwrap(), + "rust.action", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_global( + &parse_sequence("s").unwrap(), + "global.action", + SourceLocation::default(), + ) + .unwrap(); + + render_command(&mut reg, &cmds, &kms, "rust.action").unwrap(); + let body = read_help(®); + assert!( + body.contains("[key: s @mode:rust]"), + "mode key link must carry its mode scope: {body}" + ); + let cursor = body.find("s @mode").unwrap() as u64; + follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let body = read_help(®); + assert!(body.contains("Scope: mode:rust"), "{body}"); + assert!(body.contains("[command: rust.action]"), "{body}"); + assert!(!body.contains("[command: global.action]"), "{body}"); + } + + #[test] + fn follow_mode_key_link_round_trips_reserved_utf8_mode_exactly() { + let lua = Lua::new(); + let mut reg = BufferRegistry::new(); + let mut cmds = CommandRegistry::new(); + let mut kms = KeymapStack::new(); + let hooks = HookRegistry::new(); + let mode = " rust]雪% @buffer:7 "; + cmds.define(make_command(&lua, "exact.mode", "Exact mode action.")) + .unwrap(); + cmds.define(make_command(&lua, "global.fallback", "Global fallback.")) + .unwrap(); + kms.bind_mode( + mode, + &parse_sequence("x").unwrap(), + "exact.mode", + SourceLocation::default(), + ) + .unwrap(); + kms.bind_global( + &parse_sequence("x").unwrap(), + "global.fallback", + SourceLocation::default(), + ) + .unwrap(); + + render_command(&mut reg, &cmds, &kms, "exact.mode").unwrap(); + let body = read_help(®); + let encoded = encode_mode_target(mode); + let link = format!("[key: x @mode:{encoded}]"); + assert!(body.contains(&link), "encoded mode link missing: {body}"); + let cursor = body.find("@mode:").unwrap() as u64; + follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap(); + let body = read_help(®); + assert!(body.contains(&format!("Scope: mode:{mode}")), "{body}"); + assert!(body.contains("[command: exact.mode]"), "{body}"); + assert!(!body.contains("[command: global.fallback]"), "{body}"); + } } diff --git a/src/keymap_stack.rs b/src/keymap_stack.rs index 42df49d..64f9eea 100644 --- a/src/keymap_stack.rs +++ b/src/keymap_stack.rs @@ -6,9 +6,8 @@ //! //! 1. **Buffer-local**: bindings that apply only when a specific //! buffer is the active one. Most-specific scope. -//! 2. **Mode**: bindings that apply when a mode is active. Modes -//! aren't a real concept until T M2.5+ but the stack accepts them -//! today so the resolver doesn't grow a dimension when we add them. +//! 2. **Mode**: bindings that apply when the active buffer's major mode +//! matches the mode keymap. //! 3. **Global**: the universal fallback. Last-resort scope. //! //! [`KeymapStack::resolve`] walks them in order and returns the @@ -38,7 +37,7 @@ use crate::keymap_tree::{Binding, Keymap, KeymapError, Resolution}; pub enum Scope { /// Buffer-local --- specific to one [`BufferId`]. Buffer(BufferId), - /// Mode-active --- one of the active mode keymaps. + /// Mode-active --- the active major mode's keymap. Mode(String), /// The global fallback. Global, @@ -91,10 +90,8 @@ pub enum StackResolution { pub struct KeymapStack { /// The global keymap (always consulted last). pub global: Keymap, - /// Per-mode keymaps. Mode activation order is preserved by `Vec`; - /// the resolver consults them after buffer-local but before - /// global. The top of the vector is the most recently activated - /// mode and wins ties. + /// Per-mode keymaps. Registration order is preserved by `Vec`; resolution + /// follows the borrowed mode names supplied to [`Self::resolve`]. pub modes: Vec<(String, Keymap)>, /// Per-buffer keymaps. Buffer-local always beats mode and global. pub buffers: HashMap, @@ -227,9 +224,8 @@ impl KeymapStack { /// Resolve `sequence` in scope priority order. /// /// `active_buffer` is the [`BufferId`] currently in focus (if any). - /// `active_modes` lists the active mode names in - /// most-recent-first order; the first match in that order wins - /// among modes. + /// `active_modes` borrows active mode names in priority order; the first + /// match in that order wins among modes. /// /// Resolution semantics: the resolver returns the *most-specific* /// complete binding it finds. If no scope has a complete match @@ -240,7 +236,7 @@ impl KeymapStack { &self, sequence: &[Chord], active_buffer: Option, - active_modes: &[String], + active_modes: &[&str], ) -> StackResolution { let mut any_pending = false; @@ -262,12 +258,12 @@ impl KeymapStack { // 2) Modes --- ordered by `active_modes`. for mode_name in active_modes { - if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == mode_name) { + if let Some((_, map)) = self.modes.iter().find(|(n, _)| n == *mode_name) { match map.lookup(sequence) { Resolution::Bound(b) => { return StackResolution::Bound(ResolvedBinding { binding: b, - scope: Scope::Mode(mode_name.clone()), + scope: Scope::Mode((*mode_name).to_owned()), }); } Resolution::Pending => any_pending = true, @@ -378,7 +374,7 @@ impl KeyDispatcher { chord: Chord, stack: &KeymapStack, active_buffer: Option, - active_modes: &[String], + active_modes: &[&str], ) -> Action { self.pending.push(chord); match stack.resolve(&self.pending, active_buffer, active_modes) { @@ -474,12 +470,12 @@ mod tests { s.bind_buffer(id, &seq("C-s"), "buffer.save", src(3)) .unwrap(); // Buffer wins. - match s.resolve(&seq("C-s"), Some(id), &["normal".into()]) { + match s.resolve(&seq("C-s"), Some(id), &["normal"]) { StackResolution::Bound(rb) => assert_eq!(rb.binding.command, "buffer.save"), other => panic!("got {other:?}"), } // No buffer: mode wins. - match s.resolve(&seq("C-s"), None, &["normal".into()]) { + match s.resolve(&seq("C-s"), None, &["normal"]) { StackResolution::Bound(rb) => { assert_eq!(rb.binding.command, "mode.save"); assert_eq!(rb.scope, Scope::Mode("normal".into())); diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index fd104ef..71d4c3a 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -3033,6 +3033,39 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result None, + Value::String(mode) => Some(mode.to_str()?.to_owned()), + other => { + return Err(mlua::Error::external(format!( + "pmacs.buffer.set_major_mode: mode must be a string or nil, got {}", + other.type_name() + ))); + } + }; + let mut r = reg.borrow_mut(); + resolve_mut(&mut r, id.0)?.set_major_mode(mode); + Ok(()) + })?, + )?; + } + { let reg = registry.clone(); buffer.set( @@ -5465,18 +5498,26 @@ fn install_help_module( help_t.set( "show_key", lua.create_function(move |lua, sequence: String| { - // Resolve against the active window's buffer so - // help.show_key surfaces buffer-local bindings --- - // mirrors the same fix as pmacs.describe.key (M8.8 - // audit finding 1). let active_buffer = lua .app_data_ref::() .map(|core| core.borrow().active_buffer_id()); + // Copy the mode name before taking the mutable registry + // borrow used to replace *help*. The borrowed slice below + // then cannot outlive or conflict with help-buffer mutation. + let active_mode = { + let r = reg.borrow(); + active_buffer + .and_then(|id| r.get(id).ok()) + .and_then(crate::buffer::Buffer::major_mode) + .map(str::to_owned) + }; + let active_mode = active_mode.as_deref(); + let active_modes = active_mode.as_slice(); let result = { let mut r = reg.borrow_mut(); let c = cmds.borrow(); let k = kms.borrow(); - help::render_key(&mut r, &c, &k, active_buffer, &sequence) + help::render_key(&mut r, &c, &k, active_buffer, active_modes, &sequence) }; if let Some((id, edits)) = result.as_ref() { queue_generated_buffer_edits(lua, *id, edits); @@ -5859,6 +5900,10 @@ fn log_buffer_removed_error(lua: &Lua, source: &SourceLocation, err: &mlua::Erro } } +#[allow( + clippy::too_many_lines, + reason = "six describe bindings share one coherent registry surface; splitting them adds ceremony without clarifying borrow lifetimes" +)] fn install_describe_module( lua: &Lua, registry: &SharedRegistry, @@ -5885,27 +5930,30 @@ fn install_describe_module( } { + let reg = registry.clone(); let cmds = commands.clone(); let kms = keymaps.clone(); describe.set( "key", lua.create_function(move |lua, sequence: String| { let chords = parse_sequence(&sequence).map_err(mlua::Error::external)?; - // Resolve against the active window's buffer scope so - // buffer-local bindings (`scope = "buffer"`) actually - // surface --- without this, a `pmacs-magit.stage` - // binding on the magit buffer is invisible to - // describe-key, even when the user is sitting on - // that buffer with their cursor. `&[]` for the - // mode list mirrors what `dispatch_key` passes - // today (no mode system yet); when modes land, both - // call sites update together. let active_buffer = lua .app_data_ref::() .map(|core| core.borrow().active_buffer_id()); - let km = kms.borrow(); - let r = km.resolve(&chords, active_buffer, &[]); - match r { + // Keep the registry borrow only across pure resolution. + // `ResolvedBinding` owns its scope, so creating the Lua + // result table cannot retain a Buffer borrow or re-enter + // Lua while one is live. + let resolution = { + let r = reg.borrow(); + let active_mode = active_buffer + .and_then(|id| r.get(id).ok()) + .and_then(crate::buffer::Buffer::major_mode); + let active_modes = active_mode.as_slice(); + let km = kms.borrow(); + km.resolve(&chords, active_buffer, active_modes) + }; + match resolution { crate::keymap_stack::StackResolution::Bound(rb) => { let cmds = cmds.borrow(); Ok(Value::Table(key_info_table( @@ -6169,6 +6217,26 @@ pub fn install_editor(lua: &Lua, core: &SharedCore) -> mlua::Result<()> { let pmacs: Table = lua.globals().get("pmacs")?; let editor = lua.create_table()?; + { + let cc = core.clone(); + let registry = core.borrow().registry.clone(); + editor.set( + "active_modes", + lua.create_function(move |lua, ()| { + let active_buffer = cc.borrow().active_buffer_id(); + let mode = { + let r = registry.borrow(); + resolve(&r, active_buffer)?.major_mode().map(str::to_owned) + }; + let modes = lua.create_table()?; + if let Some(mode) = mode { + modes.set(1, mode)?; + } + Ok(modes) + })?, + )?; + } + install_motion(&editor, lua, core)?; install_editing(&editor, lua, core)?; install_history(&editor, lua, core)?; @@ -12796,6 +12864,112 @@ mod tests { (lua, reg, cmds, kms, hks) } + fn attach_test_editor(lua: &Lua, registry: &SharedRegistry) -> SharedCore { + let core = Rc::new(RefCell::new(EditorCore::new(registry.clone()))); + install_editor(lua, &core).expect("install editor"); + core + } + + #[test] + fn buffer_major_mode_is_strict_and_rejects_stale_ids() { + let (lua, _reg, _cmds, _kms, _hks) = fresh(); + let (wrong_mode, wrong_id, stale_get, stale_set): (String, String, String, String) = lua + .load( + r#" + local id = pmacs.buffer.create("mode-test") + assert(pmacs.buffer.major_mode(id) == nil) + pmacs.buffer.set_major_mode(id, "rust") + assert(pmacs.buffer.major_mode(id) == "rust") + pmacs.buffer.set_major_mode(id, nil) + assert(pmacs.buffer.major_mode(id) == nil) + + local ok_type, err_type = + pcall(pmacs.buffer.set_major_mode, id, 42) + assert(not ok_type) + local ok_id, err_id = pcall(pmacs.buffer.major_mode, 1) + assert(not ok_id) + pmacs.buffer.remove(id) + local ok_get, err_get = pcall(pmacs.buffer.major_mode, id) + local ok_set, err_set = + pcall(pmacs.buffer.set_major_mode, id, "rust") + assert(not ok_get and not ok_set) + return tostring(err_type), tostring(err_id), + tostring(err_get), tostring(err_set) + "#, + ) + .eval() + .unwrap(); + assert!(wrong_mode.contains("string"), "{wrong_mode}"); + assert!(wrong_id.contains("buffer handle"), "{wrong_id}"); + assert!(stale_get.contains("stale buffer handle"), "{stale_get}"); + assert!(stale_set.contains("stale buffer handle"), "{stale_set}"); + } + + #[test] + fn editor_active_modes_tracks_the_active_buffers_major_mode() { + let (lua, reg, _cmds, _kms, _hks) = fresh(); + let core = attach_test_editor(&lua, ®); + let active = core.borrow().active_buffer_id(); + lua.globals() + .set("active_buffer", BufferIdLua(active)) + .unwrap(); + + lua.load( + r#" + local modes = pmacs.editor.active_modes() + assert(type(modes) == "table" and #modes == 0) + pmacs.buffer.set_major_mode(active_buffer, "rust") + modes = pmacs.editor.active_modes() + assert(#modes == 1 and modes[1] == "rust") + pmacs.buffer.set_major_mode(active_buffer, nil) + assert(#pmacs.editor.active_modes() == 0) + "#, + ) + .exec() + .unwrap(); + } + + #[test] + fn describe_key_uses_the_active_buffers_major_mode() { + let (lua, reg, _cmds, kms, _hks) = fresh(); + let core = attach_test_editor(&lua, ®); + let active = core.borrow().active_buffer_id(); + reg.borrow_mut() + .get_mut(active) + .unwrap() + .set_major_mode(Some("rust".to_owned())); + { + let mut keymaps = kms.borrow_mut(); + keymaps + .bind_global( + &parse_sequence("C-s").unwrap(), + "global.save", + SourceLocation::default(), + ) + .unwrap(); + keymaps + .bind_mode( + "rust", + &parse_sequence("C-s").unwrap(), + "rust.save", + SourceLocation::default(), + ) + .unwrap(); + } + + let (command, scope): (String, String) = lua + .load( + r#" + local info = pmacs.describe.key("C-s") + return info.command, info.scope + "#, + ) + .eval() + .unwrap(); + assert_eq!(command, "rust.save"); + assert_eq!(scope, "mode:rust"); + } + /// A theme handle with one syntax entry and nonzero counters, for /// pinning that failed commits change nothing and successful ones /// bump from the PRIOR values (themes arc Q#TH6). diff --git a/tests/mode_system_wiring_acceptance.rs b/tests/mode_system_wiring_acceptance.rs new file mode 100644 index 0000000..e1c9d0a --- /dev/null +++ b/tests/mode_system_wiring_acceptance.rs @@ -0,0 +1,500 @@ +//! Mode-system wiring acceptance over the real daemon process. +//! +//! One ordered scenario keeps the dispatch assertions observable: every +//! checkpoint is itself reached through a wire key event, and the final +//! statusline marker is published only after all Lua-side assertions pass. +//! Statusline assertions consume the daemon's real grid-render payloads. + +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::{Duration, Instant}; + +use pmacs::cell::{Cell, CellSize, Glyph}; +use pmacs::protocol::{ + AttachRequest, FrontendEvent, FrontendId, Hello, InstanceMessage, Key, KeyEvent, Modifiers, + PROTOCOL_VERSION, +}; +use pmacs::transport::{read_message, write_message}; + +mod common; +use common::daemon::{TestDaemon, build_default_caps}; + +struct Client { + stream: UnixStream, + frontend_id: FrontendId, +} + +const ROWS: u32 = 30; +const COLS: u32 = 160; + +struct Grid { + cells: Vec, +} + +impl Grid { + fn new() -> Self { + Self { + cells: vec![Cell::default(); (ROWS * COLS) as usize], + } + } + + fn apply(&mut self, spans: Vec) { + for span in spans { + let start = (span.start.row * COLS + span.start.col) as usize; + for (offset, cell) in span.cells.into_iter().enumerate() { + self.cells[start + offset] = cell; + } + } + } + + fn text(&self) -> String { + let mut text = String::with_capacity((ROWS * (COLS + 1)) as usize); + for row in 0..ROWS { + for column in 0..COLS { + let cell = &self.cells[(row * COLS + column) as usize]; + let ch = match &cell.glyph { + Glyph::Char(ch) => *ch, + Glyph::Cluster(bytes) => std::str::from_utf8(bytes) + .ok() + .and_then(|value| value.chars().next()) + .unwrap_or(' '), + Glyph::Continuation => ' ', + }; + text.push(ch); + } + text.push('\n'); + } + text + } +} + +fn attach(daemon: &TestDaemon) -> (Client, Grid) { + let mut stream = daemon.connect(); + stream + .set_read_timeout(Some(Duration::from_millis(100))) + .expect("set daemon read timeout"); + let hello: Hello = read_message(&mut stream).expect("read daemon Hello"); + assert_eq!(hello.protocol_version, PROTOCOL_VERSION); + write_message( + &mut stream, + &AttachRequest { + protocol_version: PROTOCOL_VERSION, + frontend_capabilities: build_default_caps(), + initial_size: CellSize::new(ROWS, COLS), + }, + ) + .expect("attach grid frontend"); + + let deadline = Instant::now() + Duration::from_secs(5); + let mut grid = Grid::new(); + loop { + assert!( + Instant::now() < deadline, + "initial full-grid frame timed out" + ); + if let Ok(InstanceMessage::CellDelta { + spans, + full_grid: true, + }) = read_message::(&mut stream) + { + grid.apply(spans); + break; + } + } + + ( + Client { + stream, + frontend_id: hello.assigned_frontend_id, + }, + grid, + ) +} + +fn send_key(client: &mut Client, key: Key, mods: Modifiers) { + write_message( + &mut client.stream, + &FrontendEvent::Key(KeyEvent { + frontend_id: client.frontend_id, + key, + mods, + timestamp_ns: 0, + }), + ) + .expect("send daemon key event"); +} + +fn send_ctrl_chord(client: &mut Client, second: char) { + send_key(client, Key::Char('c'), Modifiers::CTRL); + send_key(client, Key::Char(second), Modifiers::CTRL); +} + +fn checkpoint(client: &mut Client, n: u8) { + send_key(client, Key::F(n), Modifiers::NONE); +} + +fn pump_grid_until( + client: &mut Client, + grid: &mut Grid, + what: &str, + predicate: impl Fn(&str) -> bool, +) -> String { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let text = grid.text(); + assert!( + !text.contains("MSW_ERROR:"), + "daemon-side Lua checkpoint failed:\n{text}" + ); + if predicate(&text) { + return text; + } + assert!( + Instant::now() < deadline, + "grid update timed out waiting for {what}; current grid:\n{text}" + ); + if let Ok(InstanceMessage::CellDelta { spans, .. }) = + read_message::(&mut client.stream) + { + grid.apply(spans); + } + } +} + +fn lua_string(path: &Path) -> String { + format!("{:?}", path.to_string_lossy()) +} + +#[allow( + clippy::too_many_lines, + reason = "one ordered daemon session preserves dispatch state across all ten acceptance checks" +)] +#[test] +fn mode_system_wiring_is_observable_end_to_end() { + let fixtures = tempfile::tempdir().expect("fixture tempdir"); + let rust = fixtures.path().join("dispatch.rs"); + let python = fixtures.path().join("dispatch.py"); + let unknown = fixtures.path().join("dispatch.txt"); + let server = fixtures.path().join("dispatch.msw"); + std::fs::write(&rust, "// MSW_RUST_FIXTURE\nfn main() {}\n").unwrap(); + std::fs::write(&python, "# MSW_PYTHON_FIXTURE\nprint('ok')\n").unwrap(); + std::fs::write(&unknown, "MSW_UNKNOWN_FIXTURE\n").unwrap(); + std::fs::write(&server, "MSW_SERVER_ONLY_FIXTURE\n").unwrap(); + + let init_template = r#" +-- Ordinary fixtures must never inherit the built-in real-server registry. +pmacs.lsp.config = {} +pmacs.lsp.filetypes.msw = "serveronly" + +local RUST_PATH = __RUST_PATH__ +local PYTHON_PATH = __PYTHON_PATH__ +local UNKNOWN_PATH = __UNKNOWN_PATH__ +local SERVER_PATH = __SERVER_PATH__ +local S = { rust_hits = 0 } +_G.MSW_STATE = S +_G.MSW_RESULT = false +_G.MSW_ERROR = nil + +local function eq(actual, expected, label) + assert(actual == expected, + label .. ": expected " .. tostring(expected) .. ", got " .. tostring(actual)) +end + +local function current_modes(expected) + local modes = pmacs.editor.active_modes() + if expected == nil then + eq(#modes, 0, "active mode count") + else + eq(#modes, 1, "active mode count") + eq(modes[1], expected, "active mode") + end +end + +local function command(name, body) + pmacs.command.define { + name = name, + description = "mode-system wiring acceptance command " .. name, + fn = function() + local ok, err = pcall(body) + if not ok then + _G.MSW_ERROR = ("MSW_ERROR:" .. tostring(err)):sub(1, 200) + end + end, + } +end + +local function global_key(sequence, name) + pmacs.keymap.bind { scope = "global", sequence = sequence, command = name } +end + +command("test.rust-only", function() + S.rust_hits = S.rust_hits + 1 +end) +command("test.priority-buffer", function() S.priority_hit = "buffer" end) +command("test.priority-mode", function() S.priority_hit = "mode" end) +command("test.priority-global", function() S.priority_hit = "global" end) +command("test.parity-mode", function() S.parity_hit = "mode" end) +command("test.parity-global", function() S.parity_hit = "global" end) + +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-c", command = "test.rust-only", +} +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-p", command = "test.priority-mode", +} +pmacs.keymap.bind { + scope = "global", sequence = "C-c C-p", command = "test.priority-global", +} +pmacs.keymap.bind { + scope = "mode", mode = "rust", sequence = "C-c C-d", command = "test.parity-mode", +} +pmacs.keymap.bind { + scope = "global", sequence = "C-c C-d", command = "test.parity-global", +} + +pmacs.statusline.register { + name = "mode-system-result", + side = "right", + priority = 999, + face = "ui.modeline", + fn = function() + if _G.MSW_ERROR then return _G.MSW_ERROR end + if _G.MSW_RESULT then return "MODE-SYSTEM-WIRING-PASS" end + return nil + end, +} + +command("test.step-1", function() + local provider + for _, candidate in ipairs(pmacs.statusline.providers()) do + if candidate.name == "mode" then provider = candidate end + end + assert(provider ~= nil, "built-in mode provider is registered") + eq(provider.side, "left", "mode provider side") + eq(provider.priority, 0, "mode provider priority") + eq(provider.face, "ui.modeline", "mode provider face") + eq(provider.enabled, true, "mode provider enabled") + + S.rust = pmacs.buffer.find_or_open(RUST_PATH) + eq(pmacs.buffer.major_mode(S.rust), "rust", "rust initialization") + current_modes("rust") + + S.python = pmacs.buffer.find_or_open(PYTHON_PATH) + eq(pmacs.buffer.major_mode(S.python), "python", "python initialization") + current_modes("python") + + S.unknown = pmacs.buffer.find_or_open(UNKNOWN_PATH) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown initialization") + current_modes(nil) + + S.server = pmacs.buffer.find_or_open(SERVER_PATH) + eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only initialization") + current_modes("serveronly") + eq(pmacs.parse._has_view(S.server), false, "server-only parse view") + + pmacs.keymap.bind { + scope = "buffer", buffer = S.rust, + sequence = "C-c C-p", command = "test.priority-buffer", + } + + -- Leave a Rust-active/Python-passive split for real statusline evaluation. + pmacs.window.switch_buffer(S.rust) + pmacs.window.split_vertical() + pmacs.window.focus_next() + pmacs.window.switch_buffer(S.python) + pmacs.window.focus_next() + eq(tostring(pmacs.window.buffer()), tostring(S.rust), "focused split buffer") + eq(pmacs.buffer.major_mode(S.rust), "rust", "rust survived switching") + eq(pmacs.buffer.major_mode(S.python), "python", "python survived switching") +end) + +command("test.step-2", function() + eq(S.rust_hits, 1, "Rust mode dispatch") + pmacs.window.switch_buffer(S.python) + current_modes("python") +end) + +command("test.step-3", function() + eq(S.rust_hits, 1, "Python must not dispatch Rust binding") + pmacs.window.switch_buffer(S.unknown) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown stays mode-less") + current_modes(nil) +end) + +command("test.step-4", function() + eq(S.rust_hits, 1, "mode-less buffer must not dispatch Rust binding") + pmacs.window.switch_buffer(S.rust) + S.priority_hit = nil +end) + +command("test.step-5", function() + eq(S.priority_hit, "buffer", "buffer scope precedence") + pmacs.keymap.unbind { + scope = "buffer", buffer = S.rust, sequence = "C-c C-p", + } + S.priority_hit = nil +end) + +command("test.step-6", function() + eq(S.priority_hit, "mode", "mode scope precedence") + pmacs.window.switch_buffer(S.python) + S.priority_hit = nil +end) + +command("test.step-7", function() + eq(S.priority_hit, "global", "global scope fallback") + pmacs.window.switch_buffer(S.rust) + S.parity_hit = nil +end) + +command("test.step-8", function() + eq(S.parity_hit, "mode", "parity binding dispatch") + + local described = pmacs.describe.key("C-c C-d") + assert(described ~= nil, "describe.key returns the mode binding") + eq(described.command, "test.parity-mode", "describe.key command") + eq(described.scope, "mode:rust", "describe.key scope") + + local help_id = pmacs.help.show_key("C-c C-d") + assert(help_id ~= nil, "help.show_key returns a help buffer") + local body = help_id:slice(0, help_id:len()) + assert(body:find("Runs: %[command: test%.parity%-mode%]"), body) + assert(body:find("Scope: mode:rust", 1, true), body) + + help_id = pmacs.help.show_command("test.parity-mode") + body = help_id:slice(0, help_id:len()) + local link_start = body:find("%[key: C%-c C%-d @mode:rust%]") + assert(link_start ~= nil, "mode command help link carries @mode:rust: " .. body) + pmacs.window.switch_buffer(help_id) + local followed = pmacs.help.follow_link(link_start + 6) + assert(followed ~= nil, "mode key link follows while *help* is active") + local followed_body = followed:slice(0, followed:len()) + assert(followed_body:find("Runs: %[command: test%.parity%-mode%]"), followed_body) + assert(followed_body:find("Scope: mode:rust", 1, true), followed_body) + + pmacs.window.switch_buffer(S.rust) + pmacs.buffer.set_major_mode(S.rust, "markdown") + pmacs.window.switch_buffer(S.python) + pmacs.window.switch_buffer(S.rust) + eq(pmacs.buffer.major_mode(S.rust), "markdown", "explicit override survives switches") + current_modes("markdown") +end) + +command("test.step-9", function() + eq(pmacs.buffer.major_mode(S.rust), "markdown", "override remains live") + pmacs.buffer.set_major_mode(S.rust, nil) + pmacs.window.switch_buffer(S.python) + pmacs.window.switch_buffer(S.rust) + eq(pmacs.buffer.major_mode(S.rust), nil, "explicit clear survives switches") + current_modes(nil) + S.clear_baseline = S.rust_hits +end) + +command("test.step-10", function() + eq(S.rust_hits, S.clear_baseline, "cleared Rust mode must not dispatch") + + pmacs.window.switch_buffer(S.server) + eq(pmacs.buffer.major_mode(S.server), "serveronly", "server-only mode survives") + current_modes("serveronly") + eq(pmacs.parse._has_view(S.server), false, "server-only language stays parser-free") + + pmacs.window.switch_buffer(S.unknown) + eq(pmacs.buffer.major_mode(S.unknown), nil, "unknown mode remains nil") + current_modes(nil) + _G.MSW_RESULT = true +end) + +for n = 1, 10 do + global_key("", "test.step-" .. n) +end +"#; + + let init = init_template + .replace("__RUST_PATH__", &lua_string(&rust)) + .replace("__PYTHON_PATH__", &lua_string(&python)) + .replace("__UNKNOWN_PATH__", &lua_string(&unknown)) + .replace("__SERVER_PATH__", &lua_string(&server)); + + let daemon = TestDaemon::spawn_with_config(&init); + let (mut client, mut grid) = attach(&daemon); + + // Initialize through real after-load hooks and leave Rust focused with + // Python in the passive split. The daemon's ordinary grid painter must + // render each window from its own buffer context. + checkpoint(&mut client, 1); + let split_text = pump_grid_until( + &mut client, + &mut grid, + "Rust-active/Python-passive mode lines", + |text| { + text.contains("dispatch.rs") + && text.contains("dispatch.py") + && text.contains("(rust)") + && text.contains("(python)") + }, + ); + let split_modeline = split_text + .lines() + .find(|line| line.contains("dispatch.rs") && line.contains("dispatch.py")) + .expect("both split mode lines occupy the split's modeline row"); + assert!(split_modeline.contains("(rust)"), "{split_modeline}"); + assert!(split_modeline.contains("(python)"), "{split_modeline}"); + + // 1-2: the exact mode binding fires in Rust, not Python or no-mode text. + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 2); + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 3); + send_ctrl_chord(&mut client, 'c'); + + // The active unknown-language pane omits its mode segment while the + // passive Python pane retains its own. This distinguishes empty output + // from a provider accidentally reading the focused/other buffer. + let unknown_text = pump_grid_until(&mut client, &mut grid, "mode-less active buffer", |text| { + text.contains("dispatch.txt") && text.contains("dispatch.py") + }); + let unknown_modeline = unknown_text + .lines() + .find(|line| line.contains("dispatch.txt") && line.contains("dispatch.py")) + .expect("unknown and passive Python mode lines share a row"); + let passive_start = unknown_modeline + .find("dispatch.py") + .expect("passive Python buffer name"); + assert!( + !unknown_modeline[..passive_start].contains('('), + "unknown-language mode line must omit a mode segment: {unknown_modeline}" + ); + assert!( + unknown_modeline[passive_start..].contains("(python)"), + "passive Python mode line keeps its own mode: {unknown_modeline}" + ); + + // 5: buffer-local, then mode, then global, all driven through dispatch. + checkpoint(&mut client, 4); + send_ctrl_chord(&mut client, 'p'); + checkpoint(&mut client, 5); + send_ctrl_chord(&mut client, 'p'); + checkpoint(&mut client, 6); + send_ctrl_chord(&mut client, 'p'); + + // 10: first prove the mode binding dispatched, then compare describe, + // show-key, and followed @mode link rendering against that result. + checkpoint(&mut client, 7); + send_ctrl_chord(&mut client, 'd'); + checkpoint(&mut client, 8); + + // 7-8: override and clear each survive switch-away/back; the cleared mode + // no longer dispatches the detected-language binding. + checkpoint(&mut client, 9); + send_ctrl_chord(&mut client, 'c'); + checkpoint(&mut client, 10); + + // The marker is painted only if every Lua assertion, including + // active_modes and server-only parse gating, completed successfully. + pump_grid_until( + &mut client, + &mut grid, + "mode-system success marker", + |text| text.contains("MODE-SYSTEM-WIRING-PASS"), + ); +} diff --git a/tests/statusline_segments_acceptance.rs b/tests/statusline_segments_acceptance.rs index b2f22aa..67ce0c0 100644 --- a/tests/statusline_segments_acceptance.rs +++ b/tests/statusline_segments_acceptance.rs @@ -124,8 +124,9 @@ fn a01_04_registry_contract_limits_epochs_and_results() { assert!(baseline_mode.ends_with(" L1:C1 All ")); let initial = state.statusline_registry.borrow().providers(); - assert_eq!(initial.len(), 1, "builtin lsp provider is discoverable"); - assert_eq!(initial[0].name, "lsp"); + assert_eq!(initial.len(), 2, "builtin providers are discoverable"); + assert!(initial.iter().any(|provider| provider.name == "mode")); + assert!(initial.iter().any(|provider| provider.name == "lsp")); let before_epochs = { let registry = state.statusline_registry.borrow(); (registry.layout_epoch(), registry.face_set_epoch())