pmacs/src/help.rs

955 lines
33 KiB
Rust

// help.rs --- Help-buffer rendering and cross-reference parsing (T M2.11).
//! Renders human-readable descriptions of commands, keys, buffers,
//! modes, hooks, and views into a buffer named `*help*`. Each render
//! call replaces the buffer's content in full --- the help buffer
//! always shows the most recently described target.
//!
//! # Cross-references
//!
//! Help text uses bracketed `[type: target]` tokens for navigable
//! references. Examples:
//!
//! * `[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*]`.
//!
//! [`follow_link_at`] parses the line under a cursor, finds the
//! enclosing token (if any), and re-renders. This makes the help
//! buffer self-navigable: with a buffer-local RET binding (T M2.11
//! also installs that on first render), the user clicks through
//! related entries the way Emacs's help mode does.
use std::fmt::Write;
use crate::buffer::{Buffer, BufferId, EditOp};
use crate::buffer_registry::BufferRegistry;
use crate::command::{CommandRegistry, SourceLocation};
use crate::hook::{Hook, HookRegistry};
use crate::key::{Sequence, display_sequence, parse_sequence};
use crate::keymap_stack::{KeymapStack, Scope, StackResolution};
use crate::keymap_tree::{Binding, Keymap};
/// Canonical name for the help buffer. Looked up via
/// [`BufferRegistry::find_by_name`].
pub const HELP_BUFFER_NAME: &str = "*help*";
/// Result of a render: the buffer id of `*help*` paired with the
/// Edits produced by the content replacement, or [`None`] if the
/// described target doesn't exist (e.g. unknown command name).
///
/// # Post-audit-round-6 F31 — broadcast queueing
///
/// Returning the Edits (zero, one, or two — Delete for old
/// non-empty content + Insert for new non-empty content) lets the
/// caller queue any `crdt_op` they carry via
/// `EditorCore::queue_daemon_origin_crdt_op`. Without this, replica
/// frontends see the `*help*` repaint as `CellDelta` but never
/// update their `BufferMirror`s for the CRDT-backed `*help*`
/// buffer; subsequent optimistic edits on the replica would run
/// against stale mirror content.
pub type RenderResult = Option<(BufferId, Vec<crate::rope::Edit>)>;
// ---------------------------------------------------------------------------
// Render entry points
// ---------------------------------------------------------------------------
/// Render help for a command name. Returns the help buffer id if the
/// command exists, [`None`] otherwise.
pub fn render_command(
registry: &mut BufferRegistry,
commands: &CommandRegistry,
keymaps: &KeymapStack,
name: &str,
) -> RenderResult {
let cmd = commands.get(name)?;
let mut text = String::new();
let _ = writeln!(text, "Command: {}", cmd.name);
let _ = writeln!(text, " Source: {}", cmd.source.render());
let _ = writeln!(text);
let _ = writeln!(text, "{}", cmd.description);
let _ = writeln!(text);
write_command_bindings(registry, &mut text, &cmd.name, keymaps);
if cmd.predicate.is_some() {
let _ = writeln!(text);
let _ = writeln!(text, "Predicate: yes (this command can refuse to run).");
}
let _ = writeln!(text);
let _ = writeln!(text, "See also: [keymap: list].");
Some(replace_help_buffer(registry, &text))
}
/// Render help for a chord sequence. Returns the help buffer id if
/// the sequence resolves to a binding, [`None`] otherwise.
///
/// `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<BufferId>,
active_modes: &[&str],
sequence: &str,
) -> RenderResult {
let chords = parse_sequence(sequence).ok()?;
let resolution = keymaps.resolve(&chords, active_buffer, active_modes);
let StackResolution::Bound(rb) = resolution else {
return None;
};
let mut text = String::new();
let _ = writeln!(text, "Key: {}", display_sequence(&chords));
let _ = writeln!(text, " Scope: {}", rb.scope.render());
let _ = writeln!(text, " Source: {}", rb.binding.source.render());
let _ = writeln!(text);
let _ = writeln!(text, "Runs: [command: {}]", rb.binding.command);
if let Some(cmd) = commands.get(&rb.binding.command) {
let _ = writeln!(text);
let _ = writeln!(text, "{}", cmd.description);
}
Some(replace_help_buffer(registry, &text))
}
/// Render help for a buffer id. Returns [`None`] if the id is stale.
pub fn render_buffer(registry: &mut BufferRegistry, id: BufferId) -> RenderResult {
let body = {
let buf = registry.get(id).ok()?;
format_buffer_text(buf)
};
Some(replace_help_buffer(registry, &body))
}
fn format_buffer_text(buf: &Buffer) -> String {
let mut text = String::new();
let _ = writeln!(text, "Buffer: {}", buf.name());
let _ = writeln!(text, " Length: {} bytes", buf.len());
let _ = writeln!(text, " Modified: {}", buf.is_modified());
let _ = writeln!(text, " Views: {}", buf.view_count());
let _ = writeln!(text);
let _ = writeln!(text, "See also: [view: {}]", buf.name());
text
}
/// Render help for a mode name. Returns [`None`] if no mode keymap
/// exists.
pub fn render_mode(
registry: &mut BufferRegistry,
keymaps: &KeymapStack,
name: &str,
) -> RenderResult {
let map = keymaps
.modes
.iter()
.find(|(n, _)| n == name)
.map(|(_, m)| m)?;
let mut text = String::new();
let _ = writeln!(text, "Mode: {name}");
let _ = writeln!(text);
write_mode_bindings(&mut text, name, map);
Some(replace_help_buffer(registry, &text))
}
/// Render help for a hook name. Returns [`None`] if no such hook is
/// defined. Callbacks are listed in registration order, satisfying
/// the M2.11 acceptance bullet about `describe-hook`.
pub fn render_hook(
registry: &mut BufferRegistry,
hooks: &HookRegistry,
name: &str,
) -> RenderResult {
let hook = hooks.get(name)?;
let body = format_hook_text(hook);
Some(replace_help_buffer(registry, &body))
}
fn format_hook_text(hook: &Hook) -> String {
let mut text = String::new();
let _ = writeln!(text, "Hook: {}", hook.name);
let _ = writeln!(text, " Kind: {}", hook.kind.as_str());
let _ = writeln!(text, " Source: {}", hook.source.render());
let _ = writeln!(text);
let _ = writeln!(text, "{}", hook.description);
let _ = writeln!(text);
if hook.callbacks.is_empty() {
let _ = writeln!(text, "No callbacks attached.");
} else {
let _ = writeln!(text, "Callbacks (in execution order):");
for (i, cb) in hook.callbacks.iter().enumerate() {
let _ = writeln!(text, " {}. {}", i + 1, cb.source.render());
}
}
text
}
/// Render help for a view target. The "target" is the buffer the
/// views are attached to (looked up by id), since views aren't
/// addressable on their own from Lua. Returns [`None`] if the buffer
/// id is stale.
pub fn render_view(registry: &mut BufferRegistry, id: BufferId) -> RenderResult {
let body = {
let buf = registry.get(id).ok()?;
format_view_text(buf)
};
Some(replace_help_buffer(registry, &body))
}
fn format_view_text(buf: &Buffer) -> String {
let mut text = String::new();
let _ = writeln!(text, "Views attached to [buffer: {}]", buf.name());
let _ = writeln!(text, " Count: {}", buf.view_count());
let ids: Vec<_> = buf.view_ids().collect();
if ids.is_empty() {
let _ = writeln!(text);
let _ = writeln!(text, "No views currently attached.");
} else {
let _ = writeln!(text);
for (i, vid) in ids.iter().enumerate() {
let _ = writeln!(text, " {}. {:?}", i + 1, vid);
}
}
text
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn write_command_bindings(
registry: &BufferRegistry,
out: &mut String,
command: &str,
keymaps: &KeymapStack,
) {
let bindings: Vec<(Scope, Sequence, Binding)> = keymaps
.iter_all()
.into_iter()
.filter(|(_, _, b)| b.command == command)
.collect();
if bindings.is_empty() {
let _ = writeln!(out, "Bound to: (no keys)");
} else {
let _ = writeln!(out, "Bound to:");
for (scope, seq, _) in &bindings {
match scope {
Scope::Buffer(id) if registry.contains(*id) => {
let _ = writeln!(
out,
" [key: {} @buffer:{}] ({})",
display_sequence(seq),
id.raw(),
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,
" [key: {}] ({})",
display_sequence(seq),
scope.render()
);
}
}
}
}
}
fn is_mode_target_unreserved(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'~' | b'-')
}
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<String> {
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<BufferId>, Option<String>)> {
if let Some((sequence, raw)) = target.rsplit_once(" @buffer:") {
let raw = raw.trim().parse::<u64>().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: {} @mode:{}] -> [command: {}]",
display_sequence(&seq),
encoded_mode,
binding.command
);
}
}
fn replace_help_buffer(
registry: &mut BufferRegistry,
text: &str,
) -> (BufferId, Vec<crate::rope::Edit>) {
let id = registry
.find_by_name(HELP_BUFFER_NAME)
.unwrap_or_else(|| registry.create(HELP_BUFFER_NAME));
let buf = registry.get_mut(id).expect("just resolved");
let mut edits = Vec::new();
if !buf.is_empty() {
let len = buf.len();
if let Ok(edit) = buf.apply_edit(EditOp::Delete {
range: crate::rope::Range::new(0, len),
}) {
edits.push(edit);
}
}
if !text.is_empty()
&& let Ok(edit) = buf.apply_edit(EditOp::Insert {
pos: 0,
bytes: text.as_bytes(),
})
{
edits.push(edit);
}
// The help buffer is regenerated content; mark it clean so the
// modeline doesn't claim it has unsaved changes.
buf.mark_clean();
(id, edits)
}
// ---------------------------------------------------------------------------
// Cross-reference parsing
// ---------------------------------------------------------------------------
/// A parsed `[type: target]` token at a specific byte range in the help
/// text.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LinkSpan {
/// Byte offset of the opening `[` within the buffer.
pub start: u64,
/// Byte offset just past the closing `]`.
pub end: u64,
/// Token type: `command`, `key`, `buffer`, `mode`, `hook`, `view`,
/// or `keymap` (catch-all for cross-section pointers).
pub kind: String,
/// Token target (everything after the `:` and before the `]`,
/// trimmed).
pub target: String,
}
/// Scan `text` (viewed as a single buffer body) and return the link
/// span that contains byte offset `cursor`, if any. Cursor on a
/// non-link character returns [`None`].
#[must_use]
pub fn link_at(text: &str, cursor: u64) -> Option<LinkSpan> {
let bytes = text.as_bytes();
let cursor = usize::try_from(cursor).ok()?;
if cursor > bytes.len() {
return None;
}
// Walk left from cursor to find an opening `[` on the same line.
let line_start = bytes[..cursor]
.iter()
.rposition(|&b| b == b'\n')
.map_or(0, |p| p + 1);
let line_end = bytes[cursor.min(bytes.len())..]
.iter()
.position(|&b| b == b'\n')
.map_or(bytes.len(), |p| cursor.min(bytes.len()) + p);
let line = &text[line_start..line_end];
let cursor_in_line = cursor - line_start;
// Find every `[...:...]` on the line and return the one containing
// the cursor (or the closest one to it, biased to the right).
let mut search_from = 0;
while let Some(open_rel) = line[search_from..].find('[') {
let open = search_from + open_rel;
let close_rel = line[open..].find(']')?;
let close = open + close_rel;
let inner = &line[open + 1..close];
if let Some((kind, target)) = inner.split_once(':') {
let kind = kind.trim();
let target = target.trim();
if !kind.is_empty()
&& !target.is_empty()
&& cursor_in_line >= open
&& cursor_in_line <= close
{
let start = u64::try_from(line_start + open).ok()?;
let end = u64::try_from(line_start + close + 1).ok()?;
return Some(LinkSpan {
start,
end,
kind: kind.to_owned(),
target: target.to_owned(),
});
}
}
search_from = close + 1;
}
None
}
/// Read the help-buffer body as a UTF-8 string. Returns an empty
/// string for an empty buffer.
fn read_buffer_text(buf: &Buffer) -> String {
let len = buf.len();
if len == 0 {
return String::new();
}
let mut out = vec![0u8; len as usize];
buf.snapshot_rope().slice(0, len, &mut out);
String::from_utf8(out).unwrap_or_default()
}
/// Result of [`follow_link_at`]: the help buffer id paired with the
/// Edits produced by the re-render (zero, one, or two), or [`None`]
/// if the cursor wasn't on a recognized link. Same broadcast-queueing
/// contract as [`RenderResult`].
pub type FollowResult = Option<(BufferId, Vec<crate::rope::Edit>)>;
/// Parse the link under the cursor in the `*help*` buffer and
/// re-render. Returns the help buffer id on success.
pub fn follow_link_at(
registry: &mut BufferRegistry,
commands: &CommandRegistry,
keymaps: &KeymapStack,
hooks: &HookRegistry,
cursor: u64,
) -> FollowResult {
let text = {
let id = registry.find_by_name(HELP_BUFFER_NAME)?;
let buf = registry.get(id).ok()?;
read_buffer_text(buf)
};
let link = link_at(&text, cursor)?;
match link.kind.as_str() {
"command" => render_command(registry, commands, keymaps, &link.target),
"key" => {
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)?;
render_buffer(registry, id)
}
"mode" => render_mode(registry, keymaps, &link.target),
"hook" => render_hook(registry, hooks, &link.target),
"view" => {
let id = registry.find_by_name(&link.target)?;
render_view(registry, id)
}
// `[keymap: list]` could one day render the full keymap; for
// M2.11 it's a documentation breadcrumb that doesn't navigate.
_ => None,
}
}
// ---------------------------------------------------------------------------
// SourceLocation helper for tests
// ---------------------------------------------------------------------------
/// Used internally; exposed for the integration test in the editor
/// module that defines a test command.
#[doc(hidden)]
#[must_use]
pub fn test_source_location(file: &str, line: i32) -> SourceLocation {
SourceLocation {
file: file.to_owned(),
line,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::command::Command;
use mlua::Lua;
fn make_command(lua: &Lua, name: &str, description: &str) -> Command {
Command {
name: name.to_owned(),
description: description.to_owned(),
source: SourceLocation {
file: "test.lua".into(),
line: 1,
},
body: lua.create_function(|_, ()| Ok(())).unwrap(),
predicate: None,
}
}
fn read_help(reg: &BufferRegistry) -> String {
let id = reg.find_by_name(HELP_BUFFER_NAME).expect("help buffer");
let buf = reg.get(id).unwrap();
read_buffer_text(buf)
}
#[test]
fn render_command_writes_name_description_and_bindings() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
cmds.define(make_command(&lua, "cursor.left", "Move cursor left."))
.unwrap();
kms.bind_global(
&parse_sequence("C-b").unwrap(),
"cursor.left",
SourceLocation {
file: "default.lua".into(),
line: 1,
},
)
.unwrap();
let (id, _) = render_command(&mut reg, &cmds, &kms, "cursor.left").unwrap();
let body = read_buffer_text(reg.get(id).unwrap());
assert!(body.contains("Command: cursor.left"));
assert!(body.contains("Move cursor left."));
assert!(body.contains("[key: C-b]"));
}
#[test]
fn render_command_unknown_returns_none() {
let mut reg = BufferRegistry::new();
let cmds = CommandRegistry::new();
let kms = KeymapStack::new();
assert!(render_command(&mut reg, &cmds, &kms, "nope").is_none());
}
#[test]
fn render_key_for_bound_chord() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
cmds.define(make_command(&lua, "save", "Save buffer."))
.unwrap();
kms.bind_global(
&parse_sequence("C-x C-s").unwrap(),
"save",
SourceLocation {
file: "default.lua".into(),
line: 5,
},
)
.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]"));
assert!(body.contains("Save buffer."));
}
#[test]
fn render_key_unbound_returns_none() {
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());
}
#[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(&reg);
assert!(body.contains("Scope: mode:rust"), "{body}");
assert!(body.contains("[command: rust.save]"), "{body}");
assert!(!body.contains("[command: global.save]"), "{body}");
}
#[test]
fn render_buffer_writes_metadata() {
let mut reg = BufferRegistry::new();
let id = reg.create_from_bytes("scratch", b"hello");
let _ = render_buffer(&mut reg, id).unwrap();
let body = read_help(&reg);
assert!(body.contains("Buffer: scratch"));
assert!(body.contains("Length: 5 bytes"));
}
#[test]
fn render_hook_lists_callbacks_in_order() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut hooks = HookRegistry::new();
hooks
.define(
"demo".into(),
"Demo.".into(),
crate::hook::HookKind::AllMustSucceed,
SourceLocation {
file: "test.lua".into(),
line: 1,
},
)
.unwrap();
for line in [10, 11, 12] {
let f = lua.create_function(|_, ()| Ok(())).unwrap();
hooks
.add(
"demo",
f,
SourceLocation {
file: "init.lua".into(),
line,
},
)
.unwrap();
}
let _ = render_hook(&mut reg, &hooks, "demo").unwrap();
let body = read_help(&reg);
assert!(body.contains("Callbacks (in execution order):"));
let pos10 = body.find("init.lua:10").unwrap();
let pos11 = body.find("init.lua:11").unwrap();
let pos12 = body.find("init.lua:12").unwrap();
assert!(pos10 < pos11 && pos11 < pos12, "order off: {body}");
}
#[test]
fn render_hook_with_no_callbacks_says_so() {
let mut reg = BufferRegistry::new();
let mut hooks = HookRegistry::new();
hooks
.define(
"empty".into(),
"No subscribers.".into(),
crate::hook::HookKind::AllMustSucceed,
SourceLocation::default(),
)
.unwrap();
let _ = render_hook(&mut reg, &hooks, "empty").unwrap();
let body = read_help(&reg);
assert!(body.contains("No callbacks attached."), "{body}");
}
#[test]
fn render_mode_lists_bindings() {
let mut reg = BufferRegistry::new();
let mut kms = KeymapStack::new();
kms.bind_mode(
"demo",
&parse_sequence("C-x").unwrap(),
"x",
SourceLocation::default(),
)
.unwrap();
kms.bind_mode(
"demo",
&parse_sequence("C-y").unwrap(),
"y",
SourceLocation::default(),
)
.unwrap();
let _ = render_mode(&mut reg, &kms, "demo").unwrap();
let body = read_help(&reg);
assert!(body.contains("Mode: demo"));
assert!(body.contains("[key: C-x @mode:demo]"));
assert!(body.contains("[command: x]"));
}
#[test]
fn render_view_describes_attached_buffer() {
let mut reg = BufferRegistry::new();
let id = reg.create("viewless");
let _ = render_view(&mut reg, id).unwrap();
let body = read_help(&reg);
assert!(
body.contains("Views attached to [buffer: viewless]"),
"{body}"
);
assert!(body.contains("No views currently attached."));
}
#[test]
fn link_at_finds_command_token() {
let text = "See [command: cursor.left] for details.\n";
let cursor = text.find("cursor.left").unwrap() as u64;
let span = link_at(text, cursor).unwrap();
assert_eq!(span.kind, "command");
assert_eq!(span.target, "cursor.left");
}
#[test]
fn link_at_finds_key_token_with_spaces() {
let text = "Try [key: C-x C-s] to save.\n";
let cursor = text.find("C-x").unwrap() as u64;
let span = link_at(text, cursor).unwrap();
assert_eq!(span.kind, "key");
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(&reg, "s @mode:").unwrap();
assert_eq!(mode.as_deref(), Some(""));
assert!(parse_key_target(&reg, "s @mode:%FF").is_none());
}
#[test]
fn link_at_off_link_returns_none() {
let text = "Plain text with no [command: foo] here.\n";
// Cursor on "Plain"
assert!(link_at(text, 2).is_none());
}
#[test]
fn follow_link_at_chases_command_to_command() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let mut cmds = CommandRegistry::new();
let kms = KeymapStack::new();
let hooks = HookRegistry::new();
cmds.define(make_command(&lua, "alpha", "Alpha cmd."))
.unwrap();
cmds.define(make_command(&lua, "beta", "Beta cmd."))
.unwrap();
// Render one command, then write a manual cross-ref into the
// help buffer pointing at another, and follow it.
render_command(&mut reg, &cmds, &kms, "alpha").unwrap();
let id = reg.find_by_name(HELP_BUFFER_NAME).unwrap();
let suffix = "\nrelated: [command: beta]\n";
let buf = reg.get_mut(id).unwrap();
let pos = buf.len();
buf.apply_edit(EditOp::Insert {
pos,
bytes: suffix.as_bytes(),
})
.unwrap();
// Find cursor on the cross-ref.
let body = read_help(&reg);
let cursor = body.find("beta").unwrap() as u64;
let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
assert_eq!(returned, id);
let body = read_help(&reg);
assert!(body.contains("Command: beta"), "{body}");
}
#[test]
fn follow_link_at_chases_command_to_buffer_local_key() {
let lua = Lua::new();
let mut reg = BufferRegistry::new();
let target_buffer = reg.create("magit");
let mut cmds = CommandRegistry::new();
let mut kms = KeymapStack::new();
let hooks = HookRegistry::new();
cmds.define(make_command(&lua, "pmacs-magit.stage", "Stage item."))
.unwrap();
kms.bind_buffer(
target_buffer,
&parse_sequence("s").unwrap(),
"pmacs-magit.stage",
SourceLocation {
file: "magit.lua".into(),
line: 12,
},
)
.unwrap();
render_command(&mut reg, &cmds, &kms, "pmacs-magit.stage").unwrap();
let body = read_help(&reg);
assert!(
body.contains(&format!("[key: s @buffer:{}]", target_buffer.raw())),
"buffer-local key link must carry its buffer scope: {body}"
);
let cursor = body.find("s @buffer").unwrap() as u64;
let (returned, _) = follow_link_at(&mut reg, &cmds, &kms, &hooks, cursor).unwrap();
assert_eq!(returned, reg.find_by_name(HELP_BUFFER_NAME).unwrap());
let body = read_help(&reg);
assert!(body.contains("Key: s"), "{body}");
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(&reg);
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(&reg);
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(&reg);
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(&reg);
assert!(body.contains(&format!("Scope: mode:{mode}")), "{body}");
assert!(body.contains("[command: exact.mode]"), "{body}");
assert!(!body.contains("[command: global.fallback]"), "{body}");
}
}