955 lines
33 KiB
Rust
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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®, "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";
|
|
// 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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®);
|
|
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(®);
|
|
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}");
|
|
}
|
|
}
|