feat(plugin-api): support server-side command suggestions (#2344)

* feat(plugin-api): support server-side command suggestions

* fix(data): use deterministic block state properties

* chore: update subproject commit reference for pumpkin-plugin-wit

* fix(plugin-api): avoid unwrap in suggestion handler registration

* fix: point WIT submodule at accessible fork

* fix: restore upstream WIT submodule URL

* fix: update WIT submodule for command suggestions

* Update pumpkin-plugin-wit

---------

Co-authored-by: Alexander Medvedev <lilalexmed@proton.me>
This commit is contained in:
Niclas
2026-08-22 12:32:06 +02:00
committed by GitHub
parent 4012bdd60b
commit 342575c2fa
12 changed files with 352 additions and 50 deletions

View File

@@ -16,6 +16,9 @@ use crate::{
pub(crate) static NEXT_COMMAND_ID: AtomicU32 = AtomicU32::new(0);
pub(crate) static COMMAND_HANDLERS: Mutex<BTreeMap<u32, Box<dyn CommandHandler>>> =
Mutex::new(BTreeMap::new());
pub(crate) static COMMAND_SUGGESTION_HANDLERS: Mutex<
BTreeMap<u32, Box<dyn CommandSuggestionHandler>>,
> = Mutex::new(BTreeMap::new());
/// Handles the execution of a registered command.
///
@@ -37,6 +40,62 @@ pub trait CommandHandler: Send + Sync {
) -> Result<i32, CommandError>;
}
/// Handles server-side suggestions for a registered command argument.
///
/// ```rust,ignore
/// use pumpkin_plugin_api::command::{CommandSuggestion, CommandSuggestions, SuggestionRequest};
/// use pumpkin_plugin_api::commands::CommandSuggestionHandler;
/// use pumpkin_plugin_api::Server;
///
/// struct PatternSuggestions;
///
/// impl CommandSuggestionHandler for PatternSuggestions {
/// fn suggest(
/// &self,
/// _sender: pumpkin_plugin_api::command::CommandSender,
/// _server: Server,
/// request: SuggestionRequest,
/// ) -> CommandSuggestions {
/// let token_start = request
/// .input
/// .rfind([',', ' '])
/// .map_or(request.start as usize, |index| index + 1);
/// let block_start = request.input[token_start..]
/// .rfind('%')
/// .map_or(token_start, |index| token_start + index + 1);
/// let prefix = &request.input[block_start..];
/// let values = ["stone", "stripped_oak_log", "dirt", "diamond_block"]
/// .into_iter()
/// .filter(|block| block.starts_with(prefix))
/// .map(|block| CommandSuggestion {
/// value: block.to_string(),
/// tooltip: None,
/// })
/// .collect();
///
/// CommandSuggestions {
/// start: block_start as u32,
/// length: (request.input.len() - block_start) as u32,
/// values,
/// }
/// }
/// }
/// ```
pub trait CommandSuggestionHandler: Send + Sync {
/// Computes suggestions for the current command input.
///
/// `request.remaining` contains the replacement text currently covered by
/// the suggestion range. Handlers may return a narrower range when only part
/// of an argument should be replaced, for example after the last comma in a
/// weighted block pattern.
fn suggest(
&self,
sender: CommandSender,
server: Server,
request: SuggestionRequest,
) -> CommandSuggestions;
}
impl Command {
/// Attaches an execution handler to this command.
///
@@ -74,4 +133,26 @@ impl CommandNode {
self
}
/// Attaches a server-side suggestion handler to this argument node.
///
/// The node is advertised to Java clients with `minecraft:ask_server`, and
/// the handler is called whenever the client requests completions for this
/// argument.
pub fn suggest<H: CommandSuggestionHandler + Send + Sync + 'static>(self, handler: H) -> Self {
let id = NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed);
COMMAND_SUGGESTION_HANDLERS
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(id, Box::new(handler));
self.suggest_with_handler_id(id);
self
}
}
pub use crate::wit::pumpkin::plugin::command::{
CommandSuggestion, CommandSuggestions, SuggestionRequest,
};

View File

@@ -67,8 +67,11 @@
//! ```
use crate::{
commands::COMMAND_HANDLERS, events::EVENT_HANDLERS, logging::WitSubscriber,
scheduler::TASK_HANDLERS, text::TextComponent,
commands::{COMMAND_HANDLERS, COMMAND_SUGGESTION_HANDLERS},
events::EVENT_HANDLERS,
logging::WitSubscriber,
scheduler::TASK_HANDLERS,
text::TextComponent,
};
/// Plugin command registration and handling utilities.
@@ -98,8 +101,8 @@ pub mod worldgen;
/// Command WIT API re-exports.
pub mod command {
pub use crate::wit::pumpkin::plugin::command::{
Arg, ArgumentType, Command, CommandError, CommandNode, CommandSender, ConsumedArgs,
StringType,
Arg, ArgumentType, Command, CommandError, CommandNode, CommandSender, CommandSuggestion,
CommandSuggestions, ConsumedArgs, StringType, SuggestionRequest,
};
}
@@ -249,6 +252,27 @@ impl wit::Guest for Component {
)
}
/// WIT entry point — dispatches an incoming command suggestion request to the registered handler.
fn handle_command_suggestion(
handler_id: u32,
sender: command::CommandSender,
server: Server,
request: command::SuggestionRequest,
) -> command::CommandSuggestions {
let handlers = COMMAND_SUGGESTION_HANDLERS
.lock()
.unwrap_or_else(|e| e.into_inner());
if let Some(handler) = handlers.get(&handler_id) {
handler.suggest(sender, server, request)
} else {
command::CommandSuggestions {
start: request.start,
length: 0,
values: Vec::new(),
}
}
}
/// WIT entry point — dispatches a scheduled task invocation to the registered handler for `handler_id`.
fn handle_task(handler_id: u32, server: Server) {
let mut handlers = TASK_HANDLERS.lock().unwrap_or_else(|e| e.into_inner());

View File

@@ -257,7 +257,11 @@ fn nodes_to_proto_node_builders<'a>(
for i in children {
let node = &nodes[*i];
match &node.node_type {
NodeType::Argument { name, consumer } => {
NodeType::Argument {
name,
consumer,
suggestion_provider,
} => {
let (node_is_executable, node_children) =
nodes_to_proto_node_builders(cmd_src, nodes, &node.children);
child_nodes.push(ProtoNodeBuilder {
@@ -267,8 +271,11 @@ fn nodes_to_proto_node_builders<'a>(
is_executable: node_is_executable,
redirect_target: None,
parser: consumer.get_client_side_parser(),
override_suggestion_type: consumer
.get_client_side_suggestion_type_override(),
override_suggestion_type: if suggestion_provider.is_some() {
Some(SuggestionProviders::AskServer)
} else {
consumer.get_client_side_suggestion_type_override()
},
restricted: false,
},
});
@@ -488,7 +495,7 @@ fn collect_overloads_from_nodes(
});
collect_overloads_from_nodes(nodes, &node.children, &mut params, overloads, ctx);
}
NodeType::Argument { name, consumer } => {
NodeType::Argument { name, consumer, .. } => {
let mut params = current_params.clone();
params.push(CommandParameter {
name: name.clone(),

View File

@@ -1,5 +1,4 @@
use pumpkin_data::translation;
use pumpkin_protocol::java::client::play::CommandSuggestion;
use pumpkin_util::text::TextComponent;
use pumpkin_util::text::click::ClickEvent;
use pumpkin_util::text::color::NamedColor;
@@ -15,8 +14,12 @@ use crate::command::dispatcher::CommandError::{
CommandFailed, InvalidConsumption, InvalidRequirement, PermissionDenied, SyntaxError,
};
use crate::command::tree::{Command, CommandTree, NodeType, RawArg, RawArgs};
use crate::command::{
context::string_range::StringRange,
suggestion::{Suggestion, suggestions::Suggestions},
};
use crate::server::Server;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
#[derive(Debug)]
pub enum CommandError {
@@ -268,10 +271,10 @@ impl CommandDispatcher {
src: &'a CommandSender,
server: &'a Server,
cmd: &'a str,
) -> Vec<CommandSuggestion> {
) -> Suggestions {
let mut parts = cmd.split_whitespace();
let Some(key) = parts.next() else {
return Vec::new();
return Suggestions::empty();
};
let mut raw_args: RawArgs<'a> = parts
.rev()
@@ -284,7 +287,7 @@ impl CommandDispatcher {
.collect();
let Ok(tree) = self.get_tree(key) else {
return Vec::new();
return Suggestions::empty();
};
// Gate suggestions on command permissions, mirroring `dispatch`. Many
@@ -293,14 +296,14 @@ impl CommandDispatcher {
// consumers would leak privileged data (e.g. banned/op/whitelisted
// player names) to unprivileged players via tab-complete.
let Some(permission) = self.permissions.get(key) else {
return Vec::new();
return Suggestions::empty();
};
if !src.has_permission(server, permission.as_str()).await {
return Vec::new();
return Suggestions::empty();
}
let mut suggestions = HashSet::new();
let mut suggestions = Vec::new();
// try paths and collect the nodes that fail
// todo: make this more fine-grained
@@ -312,27 +315,27 @@ impl CommandDispatcher {
debug!(
"Error while parsing command \"{cmd}\": {s:?} was consumed, but couldn't be parsed"
);
return Vec::new();
return Suggestions::empty();
}
Err(InvalidRequirement) => {
debug!(
"Error while parsing command \"{cmd}\": a requirement that was expected was not met."
);
return Vec::new();
return Suggestions::empty();
}
Err(PermissionDenied) => {
debug!("Permission denied for command \"{cmd}\"");
return Vec::new();
return Suggestions::empty();
}
Err(CommandFailed(_)) => {
debug!("Command failed");
return Vec::new();
return Suggestions::empty();
}
Err(SyntaxError(_)) => {
return Vec::new();
return Suggestions::empty();
}
Ok(Some(new_suggestions)) => {
suggestions.extend(new_suggestions);
suggestions.push(new_suggestions);
}
Ok(None) => {
debug!("Command none");
@@ -340,9 +343,7 @@ impl CommandDispatcher {
}
}
let mut suggestions = Vec::from_iter(suggestions);
suggestions.sort_by(|a, b| a.suggestion.cmp(&b.suggestion));
suggestions
Suggestions::merge(cmd, suggestions)
}
#[allow(clippy::too_many_lines)]
@@ -621,7 +622,7 @@ impl CommandDispatcher {
tree: &'a CommandTree,
raw_args: &mut RawArgs<'a>,
input: &'a str,
) -> Result<Option<Vec<CommandSuggestion>>, CommandError> {
) -> Result<Option<Suggestions>, CommandError> {
//let mut parsed_args: ConsumedArgs = HashMap::new();
for node in path.iter().map(|&i| &tree.nodes[i]) {
@@ -634,15 +635,50 @@ impl CommandDispatcher {
return Ok(None);
}
}
NodeType::Argument { consumer, name: _ } => {
NodeType::Argument {
consumer,
suggestion_provider,
name: _,
} => {
match consumer.consume_with_syntax(src, server, raw_args).await {
Ok(Some(_consumed)) => {
//parsed_args.insert(name, consumed);
}
Ok(None) => {
return if raw_args.is_empty() {
let suggestions = consumer.suggest(src, server, input).await?;
Ok(suggestions)
let start = input
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map_or(0, |(index, c)| index + c.len_utf8());
if let Some(provider) = suggestion_provider {
Ok(Some(
provider
.suggest(src, server, input, start, input.len())
.await,
))
} else {
let suggestions = consumer.suggest(src, server, input).await?;
let range = StringRange::between(start, input.len());
Ok(suggestions.map(|suggestions| {
Suggestions::new(
range,
suggestions
.into_iter()
.map(|suggestion| match suggestion.tooltip {
Some(tooltip) => Suggestion::with_tooltip(
range,
suggestion.suggestion,
tooltip,
),
None => Suggestion::without_tooltip(
range,
suggestion.suggestion,
),
})
.collect(),
)
}))
}
} else {
Ok(None)
};

View File

@@ -712,23 +712,26 @@ impl CommandDispatcher {
/// This function currently panics if the source provided was a dummy source.
/// This is subject to change in the future.
pub async fn suggest(&self, input: &str, source: &CommandSource) -> Vec<CommandSuggestion> {
self.suggest_with_range(input, source)
.await
.suggestions
.into_iter()
.map(|suggestion| CommandSuggestion {
suggestion: suggestion.text.cached_text().clone(),
tooltip: suggestion.tooltip,
})
.collect()
}
pub async fn suggest_with_range(&self, input: &str, source: &CommandSource) -> Suggestions {
// Never suggest arguments for a command that has been turned off.
if self.is_disabled(Self::command_name(input)) {
return Vec::new();
return Suggestions::empty();
}
let future1 = async move {
let parsed = self.parse_input(input, source).await;
let suggestions = self.get_completion_suggestions_at_end(parsed).await;
suggestions
.suggestions
.into_iter()
.map(|suggestion| CommandSuggestion {
suggestion: suggestion.text.cached_text().clone(),
tooltip: suggestion.tooltip,
})
.collect::<Vec<CommandSuggestion>>()
self.get_completion_suggestions_at_end(parsed).await
};
let future2 = async move {
@@ -737,9 +740,9 @@ impl CommandDispatcher {
.await
};
let (mut a, mut b) = future::join(future1, future2).await;
a.append(&mut b);
a
let (a, b) = future::join(future1, future2).await;
let suggestions = <[Suggestions; 2]>::from((a, b));
Suggestions::merge(input, suggestions)
}
/// Gets all the commands usable in this dispatcher, sorted.

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use super::CommandExecutor;
use crate::command::CommandSender;
use crate::command::args::{ArgumentConsumer, DefaultNameArgConsumer};
use crate::command::tree::{CommandTree, Node, NodeType};
use crate::command::tree::{CommandSuggestionProvider, CommandTree, Node, NodeType};
impl CommandTree {
/// Add a child [Node] to the root of this [`CommandTree`].
@@ -127,6 +127,18 @@ impl NonLeafNodeBuilder {
self
}
#[must_use]
pub fn suggests(mut self, provider: impl CommandSuggestionProvider + 'static) -> Self {
if let NodeType::Argument {
suggestion_provider,
..
} = &mut self.node_type
{
*suggestion_provider = Some(Arc::new(provider));
}
self
}
}
/// Matches a string literal.
@@ -157,6 +169,7 @@ pub fn argument(
node_type: NodeType::Argument {
name: name.into(),
consumer: Arc::new(consumer),
suggestion_provider: None,
},
child_nodes: Vec::new(),
leaf_nodes: Vec::new(),
@@ -171,6 +184,7 @@ pub fn argument_default_name(
node_type: NodeType::Argument {
name: consumer.default_name().to_string(),
consumer: Arc::new(consumer),
suggestion_provider: None,
},
child_nodes: Vec::new(),
leaf_nodes: Vec::new(),

View File

@@ -1,5 +1,8 @@
use super::{CommandExecutor, args::ArgumentConsumer};
use crate::command::CommandSender;
use crate::command::suggestion::suggestions::Suggestions;
use crate::server::Server;
use std::pin::Pin;
use std::{borrow::Cow, collections::VecDeque, fmt::Debug, sync::Arc};
pub mod builder;
@@ -26,6 +29,19 @@ impl Debug for RawArg<'_> {
/// see [`crate::command::tree::builder::argument`]
pub type RawArgs<'a> = Vec<RawArg<'a>>;
pub type CommandSuggestionResult<'a> = Pin<Box<dyn Future<Output = Suggestions> + Send + 'a>>;
pub trait CommandSuggestionProvider: Send + Sync {
fn suggest<'a>(
&'a self,
src: &'a CommandSender,
server: &'a Server,
input: &'a str,
start: usize,
end: usize,
) -> CommandSuggestionResult<'a>;
}
#[derive(Debug, Clone)]
pub struct Node {
pub children: Vec<usize>,
@@ -43,6 +59,7 @@ pub enum NodeType {
Argument {
name: String,
consumer: Arc<dyn ArgumentConsumer + Send>,
suggestion_provider: Option<Arc<dyn CommandSuggestionProvider>>,
},
Require {
predicate: Arc<dyn Fn(&CommandSender) -> bool + Send + Sync>,

View File

@@ -597,7 +597,7 @@ impl Completer for PumpkinCommandCompleter {
candidates.push(string.clone());
}
}
NodeType::Argument { name, consumer } => {
NodeType::Argument { name, consumer, .. } => {
let suggest_future = consumer.suggest(&src, server, typing);
if let Ok(Some(suggestions)) = suggest_future.await {

View File

@@ -6,10 +6,16 @@ use pumpkin_util::text::{
};
use crate::{
command::{CommandExecutor, dispatcher::CommandError},
command::{
CommandExecutor,
context::string_range::StringRange,
dispatcher::CommandError,
suggestion::{Suggestion, suggestions::Suggestions},
tree::{CommandSuggestionProvider, CommandSuggestionResult},
},
plugin::loader::wasm::wasm_host::{
DowncastResourceExt, PluginInstance, WasmPlugin,
wit::v0_1::pumpkin::plugin::command::CommandError as CommandErrorWit,
wit::v0_1::pumpkin::plugin::command::{CommandError as CommandErrorWit, SuggestionRequest},
},
server::Server,
};
@@ -111,3 +117,91 @@ impl CommandExecutor for WasmCommandExecutor {
})
}
}
pub struct WasmCommandSuggestionProvider {
pub handler_id: u32,
pub plugin: Arc<WasmPlugin>,
pub server: Arc<Server>,
}
impl CommandSuggestionProvider for WasmCommandSuggestionProvider {
fn suggest<'a>(
&'a self,
src: &'a crate::command::CommandSender,
_server: &'a Server,
input: &'a str,
start: usize,
end: usize,
) -> CommandSuggestionResult<'a> {
Box::pin(async move {
let mut store = self.plugin.store.lock().await;
let sender_resource = match store.data_mut().add_command_sender(src.clone()) {
Ok(resource) => resource,
Err(error) => {
tracing::error!(
"Failed to create command sender resource for suggestions: {error}"
);
return Suggestions::empty();
}
};
let server_resource = match store.data_mut().add_server(self.server.clone()) {
Ok(resource) => resource,
Err(error) => {
tracing::error!("Failed to create server resource for suggestions: {error}");
return Suggestions::empty();
}
};
let request = SuggestionRequest {
input: input.to_string(),
cursor: input.len().try_into().unwrap_or(u32::MAX),
start: start.try_into().unwrap_or(u32::MAX),
remaining: input[start.min(input.len())..end.min(input.len())].to_string(),
};
let response = match self.plugin.plugin_instance {
PluginInstance::V0_1(ref plugin) => {
plugin
.call_handle_command_suggestion(
&mut *store,
self.handler_id,
sender_resource,
server_resource,
&request,
)
.await
}
};
let response = match response {
Ok(response) => response,
Err(error) => {
tracing::error!("Wasm command suggestion failed: {error}");
return Suggestions::empty();
}
};
let start = response.start as usize;
let end = start.saturating_add(response.length as usize);
let range = StringRange::between(start, end.min(input.len()));
let suggestions = response
.values
.into_iter()
.map(|suggestion| {
if let Some(tooltip) = suggestion.tooltip {
Suggestion::with_tooltip(
range,
suggestion.value,
tooltip.consume(store.data_mut()).provider,
)
} else {
Suggestion::without_tooltip(range, suggestion.value)
}
})
.collect();
Suggestions::new(range, suggestions)
})
}
}

View File

@@ -35,7 +35,7 @@ use crate::{
PluginHostState, ServerResource, TextComponentResource,
},
wit::v0_1::{
commands::executor::WasmCommandExecutor,
commands::executor::{WasmCommandExecutor, WasmCommandSuggestionProvider},
pumpkin::{
self,
plugin::{
@@ -647,6 +647,32 @@ impl pumpkin::plugin::command::HostCommandNode for PluginHostState {
Ok(())
}
async fn suggest_with_handler_id(
&mut self,
node: Resource<CommandNode>,
handler_id: u32,
) -> wasmtime::Result<()> {
let plugin = self
.plugin
.as_ref()
.and_then(std::sync::Weak::upgrade)
.ok_or_else(|| wasmtime::Error::msg("Plugin dropped"))?;
let server = self
.server
.clone()
.ok_or_else(|| wasmtime::Error::msg("Server not initialized"))?;
let provider = WasmCommandSuggestionProvider {
handler_id,
plugin,
server,
};
let resource = self.get_node_mut(&node)?;
let builder = std::mem::replace(&mut resource.provider, literal(""));
resource.provider = builder.suggests(provider);
Ok(())
}
async fn require_with_handler_id(
&mut self,
_node: Resource<CommandNode>,

View File

@@ -248,7 +248,7 @@ impl LootFunctionExt for LootFunction {
Block::properties(Block::from_state_id(state.id), state.id)
{
let actual_props = props_data.to_props();
let mut properties_to_copy = std::collections::HashMap::new();
let mut properties_to_copy = std::collections::BTreeMap::new();
for &prop_name in *properties {
if let Some((_, value)) = actual_props.iter().find(|(k, _)| k == &prop_name)
{