implemented command
refactor command to a new crate
This commit is contained in:
7
magma-command/Cargo.lock
generated
Normal file
7
magma-command/Cargo.lock
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "magma-command"
|
||||
version = "0.1.0"
|
||||
10
magma-command/Cargo.toml
Normal file
10
magma-command/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "magma-command"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1.0.149"
|
||||
|
||||
serde.workspace = true
|
||||
color-eyre.workspace = true
|
||||
48
magma-command/src/error.rs
Normal file
48
magma-command/src/error.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CommandError {
|
||||
EmptyCommand,
|
||||
IncompleteCommand {
|
||||
path: String,
|
||||
suggestions: Vec<String>,
|
||||
},
|
||||
TooManyArguments {
|
||||
path: String,
|
||||
},
|
||||
InvalidArgument {
|
||||
path: String,
|
||||
argument: String,
|
||||
expected: Vec<String>,
|
||||
},
|
||||
InvalidRedirect {
|
||||
from: String,
|
||||
to: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CommandError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EmptyCommand => write!(f, "Empty command"),
|
||||
Self::IncompleteCommand { path, suggestions } => {
|
||||
write!(f, "Incomplete command: {}\nExpected one of: {}", path, suggestions.join(", "))
|
||||
}
|
||||
Self::TooManyArguments { path } => {
|
||||
write!(f, "Too many arguments for command: {}", path)
|
||||
}
|
||||
Self::InvalidArgument { path, argument, expected } => {
|
||||
write!(
|
||||
f,
|
||||
"Invalid argument '{}' for command: {}\nExpected one of: {}",
|
||||
argument,
|
||||
path,
|
||||
expected.join(", ")
|
||||
)
|
||||
},
|
||||
Self::InvalidRedirect { from, to } => {
|
||||
write!(f, "Invalid redirect from '{}' to '{}'", from, to.join(", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for CommandError {}
|
||||
242
magma-command/src/lib.rs
Normal file
242
magma-command/src/lib.rs
Normal file
@@ -0,0 +1,242 @@
|
||||
pub mod types;
|
||||
pub mod error;
|
||||
|
||||
use types::{CommandNode, CommandTree, ParserType, StringKind};
|
||||
use error::CommandError;
|
||||
|
||||
pub struct MinecraftCommandValidator {
|
||||
root: CommandNode,
|
||||
}
|
||||
|
||||
impl MinecraftCommandValidator {
|
||||
pub fn new() -> color_eyre::Result<Self> {
|
||||
let commands_json = include_str!("../../assets/commands.json");
|
||||
let tree: CommandTree = serde_json::from_str(commands_json)?;
|
||||
Ok(Self { root: tree.root })
|
||||
}
|
||||
|
||||
pub fn validate(&self, command: &str) -> Result<ValidationResult, CommandError> {
|
||||
let tokens = self.tokenize(command);
|
||||
|
||||
if tokens.is_empty() {
|
||||
return Err(CommandError::EmptyCommand);
|
||||
}
|
||||
|
||||
let result = self.validate_tokens(&tokens, &self.root, 0, Vec::new())?;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn tokenize(&self, command: &str) -> Vec<String> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut current = String::new();
|
||||
let mut in_quotes = false;
|
||||
let mut escape_next = false;
|
||||
|
||||
for ch in command.chars() {
|
||||
if escape_next {
|
||||
current.push(ch);
|
||||
escape_next = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
match ch {
|
||||
'\\' => escape_next = true,
|
||||
'"' => {
|
||||
in_quotes = !in_quotes;
|
||||
current.push(ch);
|
||||
}
|
||||
' ' | '\t' if !in_quotes => {
|
||||
if !current.is_empty() {
|
||||
tokens.push(current.clone());
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
_ => current.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
if !current.is_empty() {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
tokens
|
||||
}
|
||||
|
||||
fn validate_tokens(
|
||||
&self,
|
||||
tokens: &[String],
|
||||
node: &CommandNode,
|
||||
index: usize,
|
||||
mut path: Vec<String>,
|
||||
) -> Result<ValidationResult, CommandError> {
|
||||
if index >= tokens.len() {
|
||||
return if node.is_executable() {
|
||||
Ok(ValidationResult {
|
||||
valid: true,
|
||||
path,
|
||||
suggestions: self.get_suggestions_from_node(node),
|
||||
})
|
||||
} else {
|
||||
Err(CommandError::IncompleteCommand {
|
||||
path: path.join(" "),
|
||||
suggestions: self.get_suggestions_from_node(node),
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
let current_token = &tokens[index];
|
||||
let children = node.children();
|
||||
|
||||
if children.is_empty() && node.redirects().is_empty() {
|
||||
return if node.is_executable() {
|
||||
Err(CommandError::TooManyArguments {
|
||||
path: path.join(" "),
|
||||
})
|
||||
} else {
|
||||
Err(CommandError::IncompleteCommand {
|
||||
path: path.join(" "),
|
||||
suggestions: self.get_suggestions_from_node(node),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !node.redirects().is_empty() {
|
||||
let nodes = self.get_redirects(&node.redirects());
|
||||
for node in nodes {
|
||||
if let Ok(result) = self.validate_tokens(tokens, node, index, path.clone()) {
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
return Err(CommandError::InvalidRedirect {
|
||||
from: path.join(" "),
|
||||
to: node.redirects().to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
for child in children {
|
||||
if let CommandNode::Literal { name, .. } = child && name == current_token {
|
||||
path.push(current_token.clone());
|
||||
return self.validate_tokens(tokens, child, index + 1, path);
|
||||
}
|
||||
}
|
||||
self.parse_args(node, tokens, index, path)
|
||||
}
|
||||
|
||||
/// an empty string value will redirect to all children
|
||||
fn get_redirects(&self, name: &[String]) -> Vec<&CommandNode> {
|
||||
self.root.children().iter()
|
||||
.filter(
|
||||
|child|
|
||||
name.contains(child.name()) || name.contains(&String::from(""))
|
||||
)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn parse_args(
|
||||
&self,
|
||||
node: &CommandNode,
|
||||
tokens: &[String],
|
||||
index: usize,
|
||||
mut path: Vec<String>
|
||||
) -> Result<ValidationResult, CommandError> {
|
||||
let token = &tokens[index];
|
||||
for child in node.children() {
|
||||
let CommandNode::Argument { name, parser, .. } = child else {
|
||||
continue;
|
||||
};
|
||||
let parser_type = ParserType::from_parser_info(parser);
|
||||
if self.is_greedy_parser(&parser_type)
|
||||
&& self.validate_argument(&tokens[index..].join(" "), &parser_type).is_ok()
|
||||
{
|
||||
path.push(format!("<{}>", name));
|
||||
return self.validate_tokens(tokens, child, tokens.len(), path);
|
||||
} else if self.validate_argument(token, &parser_type).is_ok() {
|
||||
path.push(format!("<{}>", name));
|
||||
return self.validate_tokens(tokens, child, index + 1, path);
|
||||
}
|
||||
}
|
||||
Err(CommandError::InvalidArgument {
|
||||
path: path.join(" "),
|
||||
argument: token.to_string(),
|
||||
expected: self.get_suggestions_from_node(node),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_greedy_parser(&self, parser_type: &ParserType) -> bool {
|
||||
matches!(
|
||||
parser_type,
|
||||
ParserType::String { kind: StringKind::GreedyPhrase }
|
||||
| ParserType::Message
|
||||
| ParserType::Component
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_argument(&self, value: &str, parser_type: &ParserType) -> Result<(), String> {
|
||||
match parser_type {
|
||||
ParserType::Bool => {
|
||||
if value == "true" || value == "false" {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Expected 'true' or 'false'".to_string())
|
||||
}
|
||||
}
|
||||
ParserType::Integer { min, max } => {
|
||||
let num: i32 = value.parse().map_err(|_| "Invalid integer")?;
|
||||
if let Some(min) = min && (num as f64) < *min {
|
||||
return Err(format!("Value must be >= {}", min));
|
||||
}
|
||||
if let Some(max) = max && (num as f64) > *max {
|
||||
return Err(format!("Value must be <= {}", max));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ParserType::Float { min, max } | ParserType::Double { min, max } => {
|
||||
let num: f64 = value.parse().map_err(|_| "Invalid number")?;
|
||||
if let Some(min) = min && num < *min {
|
||||
return Err(format!("Value must be >= {}", min));
|
||||
}
|
||||
if let Some(max) = max && num > *max {
|
||||
return Err(format!("Value must be <= {}", max));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ParserType::Entity { .. } => {
|
||||
//TODO: have to check amount
|
||||
if value.starts_with('@') {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Invalid entity selector".to_string())
|
||||
}
|
||||
}
|
||||
ParserType::String { .. } => Ok(()),
|
||||
ParserType::Message | ParserType::Component => Ok(()),
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_suggestions_from_node(&self, node: &CommandNode) -> Vec<String> {
|
||||
let mut suggestions = Vec::new();
|
||||
|
||||
for child in node.children() {
|
||||
match child {
|
||||
CommandNode::Literal { name, .. } => suggestions.push(name.clone()),
|
||||
CommandNode::Argument { name, .. } => suggestions.push(format!("<{}>", name)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
suggestions
|
||||
}
|
||||
|
||||
pub fn get_all_commands(&self) -> Vec<String> {
|
||||
self.get_suggestions_from_node(&self.root)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationResult {
|
||||
pub valid: bool,
|
||||
pub path: Vec<String>,
|
||||
pub suggestions: Vec<String>,
|
||||
}
|
||||
75
magma-command/src/types/enums.rs
Normal file
75
magma-command/src/types/enums.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ParserType {
|
||||
Bool,
|
||||
Double { min: Option<f64>, max: Option<f64> },
|
||||
Float { min: Option<f64>, max: Option<f64> },
|
||||
Integer { min: Option<f64>, max: Option<f64> },
|
||||
Long { min: Option<f64>, max: Option<f64> },
|
||||
String { kind: StringKind },
|
||||
Entity { amount: EntityAmount, entity_type: EntityType },
|
||||
ScoreHolder { amount: ScoreHolderAmount },
|
||||
GameProfile,
|
||||
BlockPos,
|
||||
ColumnPos,
|
||||
Vec3,
|
||||
Vec2,
|
||||
BlockState,
|
||||
BlockPredicate,
|
||||
ItemStack,
|
||||
ItemPredicate,
|
||||
Color,
|
||||
Component,
|
||||
Message,
|
||||
Nbt,
|
||||
NbtTag,
|
||||
NbtPath,
|
||||
Objective,
|
||||
ObjectiveCriteria,
|
||||
Operation,
|
||||
Particle,
|
||||
Angle,
|
||||
Rotation,
|
||||
ScoreboardSlot,
|
||||
Swizzle,
|
||||
Team,
|
||||
ItemSlot,
|
||||
ResourceLocation { registry: Option<String> },
|
||||
Function,
|
||||
EntityAnchor,
|
||||
IntRange,
|
||||
FloatRange,
|
||||
Dimension,
|
||||
Gamemode,
|
||||
Time,
|
||||
ResourceOrTag { registry: Option<String> },
|
||||
Resource { registry: Option<String> },
|
||||
TemplateMirror,
|
||||
TemplateRotation,
|
||||
Uuid,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StringKind {
|
||||
SingleWord,
|
||||
QuotablePhrase,
|
||||
GreedyPhrase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntityAmount {
|
||||
Single,
|
||||
Multiple,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EntityType {
|
||||
Players,
|
||||
Entities,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ScoreHolderAmount {
|
||||
Single,
|
||||
Multiple,
|
||||
}
|
||||
86
magma-command/src/types/mod.rs
Normal file
86
magma-command/src/types/mod.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
mod enums;
|
||||
mod parser;
|
||||
|
||||
pub use enums::*;
|
||||
pub use parser::*;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct CommandTree {
|
||||
pub root: CommandNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "lowercase")]
|
||||
pub enum CommandNode {
|
||||
Root {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
executable: bool,
|
||||
#[serde(default)]
|
||||
redirects: Vec<String>,
|
||||
#[serde(default)]
|
||||
children: Vec<CommandNode>,
|
||||
},
|
||||
Literal {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
executable: bool,
|
||||
#[serde(default)]
|
||||
redirects: Vec<String>,
|
||||
#[serde(default)]
|
||||
children: Vec<CommandNode>,
|
||||
},
|
||||
Argument {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
executable: bool,
|
||||
#[serde(default)]
|
||||
redirects: Vec<String>,
|
||||
#[serde(default)]
|
||||
children: Vec<CommandNode>,
|
||||
parser: ParserInfo,
|
||||
},
|
||||
}
|
||||
|
||||
impl CommandNode {
|
||||
pub fn name(&self) -> &String {
|
||||
match self {
|
||||
CommandNode::Root { name, .. } => name,
|
||||
CommandNode::Literal { name, .. } => name,
|
||||
CommandNode::Argument { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_executable(&self) -> bool {
|
||||
match self {
|
||||
CommandNode::Root { executable, .. } => *executable,
|
||||
CommandNode::Literal { executable, .. } => *executable,
|
||||
CommandNode::Argument { executable, .. } => *executable,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn children(&self) -> &[CommandNode] {
|
||||
match self {
|
||||
CommandNode::Root { children, .. } => children,
|
||||
CommandNode::Literal { children, .. } => children,
|
||||
CommandNode::Argument { children, .. } => children,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn redirects(&self) -> &[String] {
|
||||
match self {
|
||||
CommandNode::Root { redirects, .. } => redirects,
|
||||
CommandNode::Literal { redirects, .. } => redirects,
|
||||
CommandNode::Argument { redirects, .. } => redirects,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parser(&self) -> Option<&ParserInfo> {
|
||||
match self {
|
||||
CommandNode::Argument { parser, .. } => Some(parser),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
67
magma-command/src/types/parser.rs
Normal file
67
magma-command/src/types/parser.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use crate::types::{EntityAmount, EntityType, ParserType, ScoreHolderAmount, StringKind};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct ParserInfo {
|
||||
pub parser: String,
|
||||
pub modifier: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl ParserType {
|
||||
pub fn from_parser_info(info: &ParserInfo) -> Self {
|
||||
match info.parser.as_str() {
|
||||
"brigadier:bool" => ParserType::Bool,
|
||||
"brigadier:double" => ParserType::Double { min: None, max: None },
|
||||
"brigadier:float" => ParserType::Float { min: None, max: None },
|
||||
"brigadier:integer" => ParserType::Integer { min: None, max: None },
|
||||
"brigadier:long" => ParserType::Long { min: None, max: None },
|
||||
"brigadier:string" => ParserType::String { kind: StringKind::SingleWord },
|
||||
"minecraft:entity" => ParserType::Entity {
|
||||
amount: EntityAmount::Multiple,
|
||||
entity_type: EntityType::Entities,
|
||||
},
|
||||
"minecraft:score_holder" => ParserType::ScoreHolder {
|
||||
amount: ScoreHolderAmount::Multiple,
|
||||
},
|
||||
"minecraft:game_profile" => ParserType::GameProfile,
|
||||
"minecraft:block_pos" => ParserType::BlockPos,
|
||||
"minecraft:column_pos" => ParserType::ColumnPos,
|
||||
"minecraft:vec3" => ParserType::Vec3,
|
||||
"minecraft:vec2" => ParserType::Vec2,
|
||||
"minecraft:block_state" => ParserType::BlockState,
|
||||
"minecraft:block_predicate" => ParserType::BlockPredicate,
|
||||
"minecraft:item_stack" => ParserType::ItemStack,
|
||||
"minecraft:item_predicate" => ParserType::ItemPredicate,
|
||||
"minecraft:color" => ParserType::Color,
|
||||
"minecraft:component" => ParserType::Component,
|
||||
"minecraft:message" => ParserType::Message,
|
||||
"minecraft:nbt_compound_tag" => ParserType::Nbt,
|
||||
"minecraft:nbt_tag" => ParserType::NbtTag,
|
||||
"minecraft:nbt_path" => ParserType::NbtPath,
|
||||
"minecraft:objective" => ParserType::Objective,
|
||||
"minecraft:objective_criteria" => ParserType::ObjectiveCriteria,
|
||||
"minecraft:operation" => ParserType::Operation,
|
||||
"minecraft:particle" => ParserType::Particle,
|
||||
"minecraft:angle" => ParserType::Angle,
|
||||
"minecraft:rotation" => ParserType::Rotation,
|
||||
"minecraft:scoreboard_slot" => ParserType::ScoreboardSlot,
|
||||
"minecraft:swizzle" => ParserType::Swizzle,
|
||||
"minecraft:team" => ParserType::Team,
|
||||
"minecraft:item_slot" => ParserType::ItemSlot,
|
||||
"minecraft:resource_location" => ParserType::ResourceLocation { registry: None },
|
||||
"minecraft:function" => ParserType::Function,
|
||||
"minecraft:entity_anchor" => ParserType::EntityAnchor,
|
||||
"minecraft:int_range" => ParserType::IntRange,
|
||||
"minecraft:float_range" => ParserType::FloatRange,
|
||||
"minecraft:dimension" => ParserType::Dimension,
|
||||
"minecraft:gamemode" => ParserType::Gamemode,
|
||||
"minecraft:time" => ParserType::Time,
|
||||
"minecraft:resource_or_tag" => ParserType::ResourceOrTag { registry: None },
|
||||
"minecraft:resource" => ParserType::Resource { registry: None },
|
||||
"minecraft:template_mirror" => ParserType::TemplateMirror,
|
||||
"minecraft:template_rotation" => ParserType::TemplateRotation,
|
||||
"minecraft:uuid" => ParserType::Uuid,
|
||||
_ => ParserType::Unknown(info.parser.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user