refactor: use dotted LSP command names

This commit is contained in:
Valdemar Lorenzen
2026-10-09 14:57:27 +02:00
parent be6be22b76
commit dbafaf6266
6 changed files with 71 additions and 41 deletions
+7 -3
View File
@@ -273,13 +273,15 @@ Servers start lazily only when a matching buffer opens. Extension matching is ca
The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses. The native module initializes each server, sends `initialization_options` as `initializationOptions`, answers `workspace/configuration` from `settings` (including dotted sections; missing sections return `null`), honors open/change/save capabilities, synchronizes in-memory text including hidden dirty buffers, and sends close events. Incremental servers receive whole-document range replacements. Process/pipe I/O and requests stay on bounded background workers; input and idle diagnostic redraw do not wait for server responses.
`:lsp_definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed. `:lsp.definition` or the configured Space d binding requests a definition at the primary caret, once regardless of selection count. Both Location and LocationLink results are supported; multiple results use the first. Local-file navigation reuses loaded buffers without overwriting unsaved text. LSP UTF-16 positions and percent-encoded file URIs are translated to validated grapheme coordinates. Navigation is ignored if its source buffer, revision, caret, or latest request has changed.
For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration; flat Ruby LSP action members have been removed without aliases. For programmatic bindings, `config.core.lsp` exposes immutable Normal-mode Action objects: `definition`, `diagnostics`, and `hover`. Each routes to the same native implementation as its Ex command; no LSP keys are bound by default. These actions are also available in the command palette. The namespace is distinct from `config.lsp[language_id]` server configuration; flat Ruby LSP action members have been removed without aliases.
Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp_diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts. Interactive LSP commands use dotted names (`:lsp.hover`, `:lsp.definition`, `:lsp.diagnostics`); underscore Ex names are removed without aliases. The existing whitespace separator keeps the whole dotted name separate from arguments. Current LSP commands take no arguments.
`:lsp_hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it. Diagnostics appear as severity-colored gutter numbers and text underlines; the primary caret's diagnostic appears in the message row. `:lsp.diagnostics` reports the current document's server state. Older versioned diagnostics are discarded; unversioned diagnostics can only be associated with the arrival-time document revision. Editing clears previous annotations immediately. Server startup/transport failures are reported and clear that server's diagnostics; other servers and native editing remain available. There are no automatic restarts.
`:lsp.hover` or a configured hover action requests information at the primary caret once, regardless of count or selection count. The response opens a read-only floating popup: arrows/Ctrl+h/j/k/l scroll vertically/horizontally, Home/End select the first/last logical line, and Escape dismisses without collapsing carets. Other input dismisses and continues normal editing. Changes to the source buffer/revision/caret, intervening input, or newer requests prevent pending hover replies from reopening it.
Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized. Hover accepts plaintext/Markdown MarkupContent and string/code MarkedString forms, including arrays. Multiline content is preserved; Markdown is shown as plain text, not rendered markup. Horizontal scrolling exposes long lines without splitting graphemes. Missing/unsupported/invalid hover results report a message without changing text, selections, or undo history. Content is bounded to 4 MiB and terminal controls are sanitized.
@@ -319,6 +321,8 @@ Plugin paths resolve relative to the config file, not the shell's working direct
`config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility. `config.core` exposes the built-in actions below as immutable `Action` values; `config.modes` exposes immutable `Mode` values. Misspelled methods fail during configuration evaluation, and `bind` rejects the wrong value type. Built-in action strings and mode strings are not accepted. Registered Ex/plugin commands still use exact string names (`"w"`, `"greet"`, etc., without `:` or arguments) and can only be bound in Normal mode. Ruby provides runtime checks, not compile-time checking; the editor still validates action/mode compatibility.
Registered command names may contain dot-separated segments, each starting with an ASCII letter followed by ASCII letters/digits/underscores/hyphens, plus one optional trailing `!`. Names cannot contain whitespace, empty segments, or leading/trailing dots. This grammar applies to core and plugin commands alike.
Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive: Key notation follows [Herdr's readable style](https://herdr.dev/docs/configuration/#keybindings). Literal non-control Unicode characters are case-sensitive (`n`, `J`, `+`); named keys and modifiers are case-insensitive:
- Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`. - Named keys: `esc`, `enter`, `backspace`, `delete`, `left`, `right`, `up`, `down`, `home`, `end`, `tab`, `space`.
+42 -16
View File
@@ -464,13 +464,13 @@ const CORE_ACTIONS: &[(&str, Action, &[Mode])] = &[
&[Mode::Normal, Mode::Visual], &[Mode::Normal, Mode::Visual],
), ),
( (
"core.lsp_definition", "core.lsp.definition",
Action::LspDefinition, Action::LspDefinition,
&[Mode::Normal], &[Mode::Normal],
), ),
("core.lsp_hover", Action::LspHover, &[Mode::Normal]), ("core.lsp.hover", Action::LspHover, &[Mode::Normal]),
( (
"core.lsp_diagnostics", "core.lsp.diagnostics",
Action::LspDiagnostics, Action::LspDiagnostics,
&[Mode::Normal], &[Mode::Normal],
), ),
@@ -953,9 +953,9 @@ impl Editor {
"bprev", "bprev",
"bdelete", "bdelete",
"bdelete!", "bdelete!",
"lsp_definition", "lsp.definition",
"lsp_hover", "lsp.hover",
"lsp_diagnostics", "lsp.diagnostics",
] ]
.into_iter() .into_iter()
.map(str::to_owned) .map(str::to_owned)
@@ -2113,9 +2113,9 @@ impl Editor {
} }
Action::FilePicker => self.open_file_picker(FilePresentation::Floating), Action::FilePicker => self.open_file_picker(FilePresentation::Floating),
Action::FileExplorer => self.open_file_picker(FilePresentation::Docked), Action::FileExplorer => self.open_file_picker(FilePresentation::Docked),
Action::LspDefinition => return self.execute("lsp_definition"), Action::LspDefinition => return self.execute("lsp.definition"),
Action::LspHover => return self.execute("lsp_hover"), Action::LspHover => return self.execute("lsp.hover"),
Action::LspDiagnostics => return self.execute("lsp_diagnostics"), Action::LspDiagnostics => return self.execute("lsp.diagnostics"),
Action::Insert Action::Insert
| Action::InsertFirstNonblank | Action::InsertFirstNonblank
| Action::AppendLineEnd | Action::AppendLineEnd
@@ -2537,10 +2537,10 @@ impl Editor {
}); });
} }
match name { match name {
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" if !args.is_empty() => { "lsp.definition" | "lsp.hover" | "lsp.diagnostics" if !args.is_empty() => {
self.set_message(format!("The {name} command takes no arguments")); self.set_message(format!("The {name} command takes no arguments"));
} }
"lsp_definition" | "lsp_hover" | "lsp_diagnostics" => { "lsp.definition" | "lsp.hover" | "lsp.diagnostics" => {
return Some(Invocation { return Some(Invocation {
plugin: "core".into(), plugin: "core".into(),
command: name.into(), command: name.into(),
@@ -4043,9 +4043,11 @@ fn word_tokens(
fn valid_command(command: &str) -> bool { fn valid_command(command: &str) -> bool {
let command = command.strip_suffix('!').unwrap_or(command); let command = command.strip_suffix('!').unwrap_or(command);
let mut chars = command.chars(); command.split('.').all(|segment| {
chars.next().is_some_and(|ch| ch.is_ascii_alphabetic()) let mut chars = segment.chars();
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') chars.next().is_some_and(|ch| ch.is_ascii_alphabetic())
&& chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
})
} }
pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> { pub(crate) fn validate_hover_text(text: &str) -> Result<(), String> {
@@ -4265,15 +4267,25 @@ mod tests {
let text = editor.context().text; let text = editor.context().text;
let revision = editor.text_revision(); let revision = editor.text_revision();
let history = (editor.active.undo.len(), editor.active.redo.len()); let history = (editor.active.undo.len(), editor.active.redo.len());
for command in [
":lsp_hover",
":lsp_definition",
":lsp_diagnostics",
":lsp.hover unexpected extra",
":lsp.definition unexpected extra",
":lsp.diagnostics unexpected extra",
] {
assert!(editor.execute(command).is_none(), "{command}");
}
editor editor
.bind("normal", "K", Some("core.lsp_hover".into())) .bind("normal", "K", Some("core.lsp.hover".into()))
.unwrap(); .unwrap();
type_text(&mut editor, "9"); type_text(&mut editor, "9");
assert_eq!( assert_eq!(
editor.handle(Input::Char('K')), editor.handle(Input::Char('K')),
Some(Invocation { Some(Invocation {
plugin: "core".into(), plugin: "core".into(),
command: "lsp_hover".into(), command: "lsp.hover".into(),
args: String::new(), args: String::new(),
}) })
); );
@@ -9513,6 +9525,20 @@ mod tests {
.is_err() .is_err()
); );
assert!(editor.register(plugin("invalid", &["bad name"])).is_err()); assert!(editor.register(plugin("invalid", &["bad name"])).is_err());
for name in [
".hover",
"lsp.",
"lsp..hover",
"lsp.3hover",
"lsp.hover!extra",
] {
assert!(
editor
.register(plugin("invalid", &["newcmd", name]))
.is_err()
);
assert!(editor.bind("normal", "g", Some("newcmd".into())).is_err());
}
assert!(editor.register(plugin("", &["emptyname"])).is_err()); assert!(editor.register(plugin("", &["emptyname"])).is_err());
let mut feature = plugin("forged", &["override"]); let mut feature = plugin("forged", &["override"]);
feature.core = true; feature.core = true;
+1 -1
View File
@@ -38,7 +38,7 @@ Set VAMP_RUBY to choose the Ruby executable.
Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset Native LSP is disabled by default: config.lsp[\"rust\"] = true enables a preset
Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] } Custom languages: config.lsp[\"my-language\"] = { command: [...], extensions: [...] }
LSP commands: :lsp_definition, :lsp_diagnostics, :lsp_hover LSP commands: :lsp.definition, :lsp.diagnostics, :lsp.hover
Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)"; Hover binding: config.bind \"K\", config.core.lsp.hover (no default key)";
struct Options { struct Options {
+1 -1
View File
@@ -71,7 +71,7 @@ module Vamp
module Lsp module Lsp
%w[definition diagnostics hover].each do |name| %w[definition diagnostics hover].each do |name|
action = Action.new("core.lsp_#{name}".freeze).freeze action = Action.new("core.lsp.#{name}".freeze).freeze
define_singleton_method(name) { action } define_singleton_method(name) { action }
end end
end end
+3 -3
View File
@@ -160,7 +160,7 @@ impl EditorSession {
fn dispatch(&mut self, invocation: Invocation) { fn dispatch(&mut self, invocation: Invocation) {
if invocation.plugin == "core" { if invocation.plugin == "core" {
let result = match invocation.command.as_str() { let result = match invocation.command.as_str() {
"lsp_definition" | "lsp_hover" => { "lsp.definition" | "lsp.hover" => {
if let Some(id) = self.lsp_request_id.checked_add(1) { if let Some(id) = self.lsp_request_id.checked_add(1) {
self.lsp_request_id = id; self.lsp_request_id = id;
let context = self.editor.context(); let context = self.editor.context();
@@ -170,7 +170,7 @@ impl EditorSession {
}; };
self.requests.entry("core".into()).or_default().latest = Some((id, source)); self.requests.entry("core".into()).or_default().latest = Some((id, source));
self.lsp_source_cursor = Some(self.editor.cursor()); self.lsp_source_cursor = Some(self.editor.cursor());
self.pending_hover = invocation.command == "lsp_hover"; self.pending_hover = invocation.command == "lsp.hover";
if self.pending_hover { if self.pending_hover {
self.lsp.hover(&context, id) self.lsp.hover(&context, id)
} else { } else {
@@ -180,7 +180,7 @@ impl EditorSession {
Err("LSP request IDs exhausted".into()) Err("LSP request IDs exhausted".into())
} }
} }
"lsp_diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()), "lsp.diagnostics" => self.lsp.diagnostics(self.editor.buffer_id()),
_ => unreachable!("session-dispatched core command"), _ => unreachable!("session-dispatched core command"),
}; };
if let Err(error) = result { if let Err(error) = result {
+17 -17
View File
@@ -435,7 +435,7 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
); );
session.execute("buffer 1"); session.execute("buffer 1");
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id session.editor().buffer_id() == target_id
}); });
@@ -447,7 +447,7 @@ fn lsp_framing_lifecycle_diagnostics_and_unicode_dirty_definition() {
.contains("first of 2 definitions") .contains("first of 2 definitions")
); );
session.execute("buffer 1"); session.execute("buffer 1");
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().message().contains("no definition found") session.editor().message().contains("no definition found")
}); });
@@ -499,7 +499,7 @@ fn lsp_initialization_and_incremental_sync_do_not_block_native_typing() {
session.handle(Input::Escape); session.handle(Input::Escape);
assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]); assert_eq!(session.editor().lines(), &["A😀e\u{301}Z!", ""]);
assert_eq!(fixture.method_count("textDocument/didOpen"), 0); assert_eq!(fixture.method_count("textDocument/didOpen"), 0);
session.execute("lsp_diagnostics"); session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().message().contains("server initializing") session.editor().message().contains("server initializing")
}); });
@@ -554,7 +554,7 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |_| { until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1 fixture.method_count("textDocument/definition") == 1
}); });
@@ -584,7 +584,7 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |_| { until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1 fixture.method_count("textDocument/definition") == 1
}); });
@@ -607,8 +607,8 @@ fn lsp_old_source_and_superseded_definitions_never_steal_focus() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_definition"); session.execute("lsp.definition");
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().path() == Some(fixture.target.as_path()) session.editor().path() == Some(fixture.target.as_path())
}); });
@@ -648,7 +648,7 @@ fn lsp_server_failures_and_unsupported_results_leave_native_editor_available() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session session
.editor() .editor()
@@ -746,7 +746,7 @@ fn lsp_definition_reuses_dirty_targets_without_an_enabled_target_language() {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
let target_id = edit_target(&mut session, &fixture); let target_id = edit_target(&mut session, &fixture);
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().buffer_id() == target_id session.editor().buffer_id() == target_id
}); });
@@ -764,7 +764,7 @@ fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_definition"); session.execute("lsp.definition");
until(&mut session, &fixture, |_| { until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/definition") == 1 fixture.method_count("textDocument/definition") == 1
}); });
@@ -785,7 +785,7 @@ fn lsp_definition_does_not_move_a_caret_that_has_moved_since_the_request() {
fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() { fn lsp_only_starts_an_explicitly_configured_server_when_a_matching_buffer_opens() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut session = fixture.open("irrelevant"); let mut session = fixture.open("irrelevant");
session.execute("lsp_diagnostics"); session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().message().contains("no running server") session.editor().message().contains("no running server")
}); });
@@ -873,12 +873,12 @@ fn lsp_hover_typed_action_preserves_multiline_content_and_primary_utf16_position
fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_requests() { fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_requests() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut session = fixture.open("hover-gated"); let mut session = fixture.open("hover-gated");
session.execute("lsp_diagnostics"); session.execute("lsp.diagnostics");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().message().contains("server initializing") session.editor().message().contains("server initializing")
}); });
assert_eq!(fixture.method_count("textDocument/hover"), 0); assert_eq!(fixture.method_count("textDocument/hover"), 0);
session.execute("lsp_hover"); session.execute("lsp.hover");
fixture.release(); fixture.release();
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().hover().is_some() session.editor().hover().is_some()
@@ -891,7 +891,7 @@ fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_reque
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_hover"); session.execute("lsp.hover");
until(&mut session, &fixture, |_| { until(&mut session, &fixture, |_| {
fixture.method_count("textDocument/hover") == 1 fixture.method_count("textDocument/hover") == 1
}); });
@@ -911,8 +911,8 @@ fn lsp_hover_queues_before_initialize_and_ignores_cancelled_and_superseded_reque
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_hover"); session.execute("lsp.hover");
session.execute("lsp_hover"); session.execute("lsp.hover");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().hover().is_some() session.editor().hover().is_some()
}); });
@@ -932,7 +932,7 @@ fn lsp_hover_absent_unsupported_and_invalid_results_preserve_native_editing() {
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session.editor().diagnostics().count() == 2 session.editor().diagnostics().count() == 2
}); });
session.execute("lsp_hover"); session.execute("lsp.hover");
until(&mut session, &fixture, |session| { until(&mut session, &fixture, |session| {
session session
.editor() .editor()