Files
PocketMine-MP/src/command/overload/CommandOverload.php
Dylan K. Taylor a314d2dbb1 Fixed trailing literals not being required
this was a problem with cmdalias, where just writing cmdalias alone would invoke cmdalias list.
In itself this wasn't particularly harmful, but it could've been a problem for other commands like timings.
2025-10-12 14:37:49 +01:00

245 lines
7.8 KiB
PHP

<?php
/*
*
* ____ _ _ __ __ _ __ __ ____
* | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
* | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
* | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
* |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* @author PocketMine Team
* @link http://www.pocketmine.net/
*
*
*/
declare(strict_types=1);
namespace pocketmine\command\overload;
use DaveRandom\CallbackValidator\ParameterInfo;
use DaveRandom\CallbackValidator\Prototype;
use DaveRandom\CallbackValidator\ReturnInfo;
use DaveRandom\CallbackValidator\Type\BuiltInType;
use DaveRandom\CallbackValidator\Type\NamedType;
use pocketmine\command\CommandSender;
use pocketmine\command\utils\InvalidCommandSyntaxException;
use pocketmine\lang\Translatable;
use pocketmine\permission\PermissionManager;
use pocketmine\utils\AssumptionFailedError;
use function array_key_last;
use function count;
use function get_class;
use function implode;
use function is_string;
use function preg_match;
use function strlen;
use function strpos;
final class CommandOverload{
private int $requiredInputCount;
/**
* @var string[]
* @phpstan-var list<string>
*/
private array $permissions;
/**
* @param Parameter[]|string[] $parameters
* @param string|string[] $permission
* @phpstan-param list<Parameter<*>|string> $parameters
* @phpstan-param string|list<string> $permission
* @phpstan-param anyClosure $handler
*/
public function __construct(
private array $parameters,
string|array $permission,
private \Closure $handler,
private bool $acceptsAliasUsed = false
){
foreach($this->parameters as $k => $parameter){
if(!is_string($parameter) && $parameter->consumesAllRemainingInputs() && $k !== array_key_last($this->parameters)){
throw new \InvalidArgumentException($parameter::class . " can only be used as the final argument, because it consumes all remaining inputs");
}
}
$permissions = is_string($permission) ? [$permission] : $permission;
if(count($permissions) === 0){
throw new \InvalidArgumentException("At least one permission must be provided");
}
$permissionManager = PermissionManager::getInstance();
foreach($permissions as $perm){
if($permissionManager->getPermission($perm) === null){
throw new \InvalidArgumentException("Cannot use non-existing permission \"$perm\"");
}
}
$this->permissions = $permissions;
//TODO: auto infer parameter infos if they aren't provided?
//TODO: allow the type of CommandSender to be constrained - this can be useful for player-only commands etc
$nonInputParameters = self::alwaysPresentArgs($this->acceptsAliasUsed);
$literalCount = 0;
$inputParameters = [];
foreach($this->parameters as $parameter){
if(is_string($parameter)){
$literalCount++;
}else{
$inputParameters[] = new ParameterInfo(
$parameter->getCodeName(),
$parameter->getCodeType(),
byReference: false,
isOptional: false,
isVariadic: false,
);
}
}
$expectedPrototype = new Prototype(
new ReturnInfo(new NamedType(BuiltInType::VOID), byReference: false),
...$nonInputParameters,
...$inputParameters
);
$actualPrototype = Prototype::fromClosure($this->handler);
if(!$expectedPrototype->isSatisfiedBy($actualPrototype)){
//validateCallableSignature() not used because we want a custom error message
throw new \InvalidArgumentException("Expected handler signature $expectedPrototype from provided parameter info, but handler has signature $actualPrototype");
}
//optionals are inferred from the prototype of the callable, not the parameter infos themselves
//contravariance allows them to be optional even if they're required in the prototype
//literals must always be provided
$this->requiredInputCount = $actualPrototype->getRequiredParameterCount() + $literalCount - count($nonInputParameters);
}
/**
* @return ParameterInfo[]
*/
private static function alwaysPresentArgs(bool $acceptsAliasUsed) : array{
$result = [new ParameterInfo("sender", new NamedType(CommandSender::class), byReference: false, isOptional: false, isVariadic: false)];
if($acceptsAliasUsed){
$result[] = new ParameterInfo("aliasUsed", new NamedType(BuiltInType::STRING), byReference: false, isOptional: false, isVariadic: false);
}
return $result;
}
/**
* @return string[]
* @phpstan-return list<string>
*/
public function getPermissions() : array{ return $this->permissions; }
public function senderHasAnyPermissions(CommandSender $sender) : bool{
foreach($this->permissions as $permission){
if($sender->hasPermission($permission)){
return true;
}
}
return false;
}
public function getUsage() : Translatable{
$templates = [];
$args = [];
$pos = 0;
foreach($this->parameters as $parameter){
if(is_string($parameter)){
//literal token
$templates[] = $parameter;
continue;
}
//TODO: printable type info would be nice
if($pos < $this->requiredInputCount){
$template = "<{%$pos}>";
}else{
$template = "[{%$pos}]";
}
$suffix = $parameter->getSuffix();
$template .= $suffix;
$templates[] = $template;
$args[] = $parameter->getPrintableName();
$pos++;
}
return new Translatable(implode(" ", $templates), $args);
}
private static function skipWhitespace(string $commandLine, int &$offset) : int{
if(preg_match('/\G\s+/', $commandLine, $matches, offset: $offset) > 0){
$offset += strlen($matches[0]);
return strlen($matches[0]);
}
return 0;
}
/**
* @throws InvalidCommandSyntaxException
*/
public function invoke(CommandSender $sender, string $aliasUsed, string $commandLine) : void{
$offset = 0;
$args = [];
$literals = 0;
//skip preceding whitespace
self::skipWhitespace($commandLine, $offset);
if($offset < strlen($commandLine)){
foreach($this->parameters as $parameter){
if(is_string($parameter)){
if(strpos($commandLine, $parameter, $offset) === $offset){
$offset += strlen($parameter);
$literals++;
}else{
throw new ParameterParseException("Literal \"$parameter\" expected");
}
}else{
try{
$args[] = $parameter->parse($commandLine, $offset);
}catch(ParameterParseException $e){
throw new ParameterParseException(
"Failed parsing argument \$" . $parameter->getCodeName() . ": " . $e->getMessage(),
previous: $e
);
}
}
if(self::skipWhitespace($commandLine, $offset) === 0){
if($offset === strlen($commandLine)){
//no more tokens, rest of the parameters must be optional
break;
}else{
if(is_string($parameter)){
throw new AssumptionFailedError();
}
throw new ParameterParseException("Parameter " . get_class($parameter) . " for \$" . $parameter->getCodeName() . " didn't stop on a whitespace character");
}
}
}
}
if($offset !== strlen($commandLine)){
throw new InvalidCommandSyntaxException("Too many arguments provided for overload");
}
if(count($args) + $literals < $this->requiredInputCount){
throw new InvalidCommandSyntaxException("Not enough arguments provided for overload");
}
//Reflection magic here :)
//TODO: maybe we don't want to invoke this directly, but hand the args back to the caller?
//this would allow resolving by more than just overload order
if($this->acceptsAliasUsed){
// @phpstan-ignore-next-line
($this->handler)($sender, $aliasUsed, ...$args);
}else{
// @phpstan-ignore-next-line
($this->handler)($sender, ...$args);
}
}
}