Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/helm-bench/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,7 @@ harness = false
name = "editor"
harness = false


[[bench]]
name = "keymap"
harness = false
143 changes: 143 additions & 0 deletions crates/helm-bench/benches/keymap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Key resolution benchmark for `helm-editor`, gated by the `bench` CI job
//! against `budgets.toml`. NF-1 gives a keypress 5 ms from input to pixel,
//! and resolving the key to a command is the first step.

use std::hint::black_box;

use criterion::{Criterion, criterion_group, criterion_main};
use helm_editor::{
Binding, CommandRegistry, Dispatcher, KeyEvent, Keymap, Keystroke, Mode, Resolution, Typing,
};

/// The chars bound with modifiers, as chord tails, and in normal mode.
const CHARS: &str = "abcdefghijklmnopqrstuvwxyz0123456789";

/// The named keys bound in insert mode with the `editor` context.
const NAMED: [&str; 12] = [
"enter",
"tab",
"backspace",
"delete",
"escape",
"left",
"right",
"up",
"down",
"home",
"end",
"pageup",
];

/// How many times the 100-event list repeats per iteration.
const REPEATS: usize = 100;

/// The two-mode keymap with the 300 bindings the task file lists, each
/// running a distinct command id.
fn build_keymap() -> Keymap {
let mut keymap = Keymap::new(
"bench",
vec![
Mode::new("insert", Typing::Insert),
Mode::new("normal", Typing::Ignore),
],
)
.expect("distinct modes");
let mut bindings = Vec::new();
for prefix in ["ctrl-", "alt-", "ctrl-alt-", "ctrl-cmd-"] {
for c in CHARS.chars() {
bindings.push(format!("{prefix}{c}"));
}
}
for chord in ["cmd-k", "cmd-j"] {
for c in CHARS.chars() {
bindings.push(format!("{chord} {c}"));
}
}
let mut count = 0;
let mut add = |keymap: &mut Keymap, binding: Binding| {
keymap.bind(binding).expect("modes exist");
count += 1;
};
for (i, keys) in bindings.iter().enumerate() {
add(
&mut keymap,
Binding::run(keys, format!("bench.c{i}")).expect("valid keys"),
);
}
for (i, c) in CHARS.chars().enumerate() {
let binding = Binding::run(&c.to_string(), format!("bench.normal{i}")).expect("valid keys");
add(&mut keymap, binding.in_mode("normal"));
}
for (i, name) in NAMED.iter().enumerate() {
for (j, prefix) in ["", "shift-", "alt-", "cmd-"].iter().enumerate() {
let binding = Binding::run(&format!("{prefix}{name}"), format!("bench.named{i}_{j}"))
.expect("valid keys");
add(&mut keymap, binding.in_mode("insert").when("editor"));
}
}
assert_eq!(count, 300);
keymap
}

/// The 100 events the task file lists: 70 typed chars, 10 bound single
/// keystrokes, 5 two-key chords and 10 bound named keys, interleaved.
fn build_events() -> Vec<KeyEvent> {
let parse = |text: &str| KeyEvent::new(Keystroke::parse(text).expect("valid keystroke"));
let typed: Vec<char> = CHARS.chars().chain([' ']).collect();
let mut events = Vec::with_capacity(100);
for i in 0..10 {
for j in 0..7 {
let c = typed[(i * 7 + j) % typed.len()];
events.push(if c == ' ' {
parse("space")
} else {
parse(&c.to_string())
});
}
let c = CHARS.as_bytes()[i * 3] as char;
let prefix = ["ctrl-", "alt-", "ctrl-alt-", "ctrl-cmd-"][i % 4];
events.push(parse(&format!("{prefix}{c}")));
let prefix = ["", "shift-", "alt-", "cmd-"][i % 4];
events.push(parse(&format!("{prefix}{}", NAMED[i])));
if i % 2 == 0 {
events.push(parse(["cmd-k", "cmd-j"][i / 2 % 2]));
events.push(parse(&(CHARS.as_bytes()[i + 10] as char).to_string()));
}
}
assert_eq!(events.len(), 100);
events
}

fn keymap(c: &mut Criterion) {
let dispatcher = Dispatcher::new(CommandRegistry::new(), build_keymap());
let mut state = dispatcher.key_state();
let events = build_events();
let contexts = ["editor"];

// Check once, outside the timed code, that every event resolves as the
// task file says: nothing left pending, and no key unbound.
for event in &events {
let resolution = dispatcher.resolve(&mut state, event, &contexts);
assert!(
matches!(resolution, Resolution::Run(_) | Resolution::Pending),
"{:?} resolved to {resolution:?}",
event.keystroke
);
}
assert!(state.pending().is_empty());

let mut group = c.benchmark_group("keymap");
group.bench_function("resolve_10k_keys_300_bindings", |b| {
b.iter(|| {
for _ in 0..REPEATS {
for event in &events {
black_box(dispatcher.resolve(&mut state, black_box(event), &contexts));
}
}
})
});
group.finish();
}

criterion_group!(benches, keymap);
criterion_main!(benches);
103 changes: 79 additions & 24 deletions crates/helm-editor/src/command.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
//! The command registry: every command has a stable string id, and runs
//! against one [`Editor`] and the [`BufferSet`] that owns its buffer.

#![expect(
unused_variables,
dead_code,
reason = "T-106 interface stubs; the implementation deletes this"
)]

use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use std::sync::Arc;

use helm_text::BufferSet;

use crate::{Editor, EditorError};
use crate::{Editor, EditorError, editor_commands};

/// A command's id, such as `"editor.delete_backward"`. Keymaps, the
/// keybindings file and the command palette name commands by id, so an id
Expand All @@ -31,26 +26,26 @@ pub struct CommandId(Arc<str>);
impl CommandId {
/// The id as text.
pub fn as_str(&self) -> &str {
todo!()
&self.0
}
}

impl From<&str> for CommandId {
fn from(id: &str) -> Self {
todo!()
Self(Arc::from(id))
}
}

impl From<String> for CommandId {
fn from(id: String) -> Self {
todo!()
Self(Arc::from(id))
}
}

impl fmt::Display for CommandId {
/// Writes the id as it is.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
todo!()
f.write_str(&self.0)
}
}

Expand Down Expand Up @@ -82,14 +77,23 @@ pub struct Invocation {
impl Invocation {
/// `command` with no arguments.
pub fn new(command: impl Into<CommandId>) -> Self {
todo!()
Self {
command: command.into(),
args: CommandArgs::default(),
}
}

/// The insert command,
/// [`editor_commands::INSERT`](crate::editor_commands::INSERT), typing
/// `text`.
pub fn insert(text: impl Into<String>) -> Self {
todo!()
Self {
command: CommandId::from(editor_commands::INSERT),
args: CommandArgs {
text: Some(text.into()),
motion: None,
},
}
}
}

Expand Down Expand Up @@ -126,13 +130,15 @@ pub struct Command {
/// `helm-ui`, which runs the ids it owns itself and hands the rest here.
#[derive(Clone, Debug, Default)]
pub struct CommandRegistry {
commands: BTreeMap<CommandId, Command>,
/// Keyed by the id's text, so [`get`](Self::get) looks up a `&str`.
/// `Arc<str>` sorts as `CommandId` does.
commands: BTreeMap<Arc<str>, Command>,
}

impl CommandRegistry {
/// An empty registry.
pub fn new() -> Self {
todo!()
Self::default()
}

/// A registry holding one command for each [`Editor`] command, under
Expand All @@ -142,18 +148,29 @@ impl CommandRegistry {
/// [`CommandArgs::text`] and errors with
/// [`CommandError::MissingText`] without it.
pub fn with_editor_commands() -> Self {
todo!()
let mut registry = Self::new();
editor_commands::register_all(&mut registry);
registry
}

/// Adds `command`. Errors, and adds nothing, if its id isn't in the
/// form [`CommandId`] describes or is already registered.
pub fn register(&mut self, command: Command) -> Result<(), RegisterError> {
todo!()
if !is_valid_id(command.id.as_str()) {
return Err(RegisterError::InvalidId { id: command.id });
}
match self.commands.entry(Arc::clone(&command.id.0)) {
Entry::Occupied(_) => Err(RegisterError::Duplicate { id: command.id }),
Entry::Vacant(entry) => {
entry.insert(command);
Ok(())
}
}
}

/// The command with id `id`, if registered.
pub fn get(&self, id: &str) -> Option<&Command> {
todo!()
self.commands.get(id)
}

/// Every registered command, sorted by id.
Expand All @@ -172,13 +189,27 @@ impl CommandRegistry {
editor: &mut Editor,
set: &mut BufferSet,
) -> Result<(), CommandError> {
todo!()
let command =
self.get(invocation.command.as_str())
.ok_or_else(|| CommandError::UnknownCommand {
id: invocation.command.clone(),
})?;
(command.run)(CommandContext {
editor,
set,
args: &invocation.args,
})
}
}

/// Whether `id` is in the form [`CommandId`] describes.
fn is_valid_id(id: &str) -> bool {
todo!()
id.split('.').all(|segment| {
!segment.is_empty()
&& segment
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
})
}

/// Why [`CommandRegistry::register`] refused a command.
Expand All @@ -198,7 +229,16 @@ pub enum RegisterError {

impl fmt::Display for RegisterError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
todo!()
match self {
Self::InvalidId { id } => write!(
f,
"command id {:?} is not dot-separated segments of a-z, 0-9 and _",
id.as_str()
),
Self::Duplicate { id } => {
write!(f, "command {:?} is already registered", id.as_str())
}
}
}
}

Expand All @@ -224,14 +264,29 @@ pub enum CommandError {

impl From<EditorError> for CommandError {
fn from(error: EditorError) -> Self {
todo!()
Self::Editor(error)
}
}

impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
todo!()
match self {
Self::UnknownCommand { id } => {
write!(f, "no command {:?} is registered", id.as_str())
}
Self::MissingText { id } => {
write!(f, "command {:?} needs text and got none", id.as_str())
}
Self::Editor(error) => write!(f, "editor command failed: {error}"),
}
}
}

impl std::error::Error for CommandError {}
impl std::error::Error for CommandError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Editor(error) => Some(error),
_ => None,
}
}
}
Loading
Loading