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
2 changes: 2 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Generated from the source, so it describes what the framework actually does.

**Attributes** — [Command](reference/attributes/command.md), [SubcommandGroup](reference/attributes/subcommand-group.md), [Subcommand](reference/attributes/subcommand.md), [Option](reference/attributes/option.md), [Event](reference/attributes/event.md), [Autocomplete](reference/attributes/autocomplete.md), [Button](reference/attributes/button.md), [SelectMenu](reference/attributes/select-menu.md), [ModalSubmit](reference/attributes/modal-submit.md)

**Options** — [Choosable](reference/options/choosable.md)

**Autocomplete** — [Autocomplete](reference/autocomplete/autocomplete.md), [ArrayAutocomplete](reference/autocomplete/array-autocomplete.md)

**Cache** — [Cache](reference/cache/cache.md)
Expand Down
18 changes: 18 additions & 0 deletions docs/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,24 @@
"methods": []
}
],
"options": [
{
"name": "Choosable",
"fqcn": "Tempcord\\Interfaces\\Choosable",
"kind": "interface",
"target": null,
"summary": "An enum that says how each of its cases should read in Discord.",
"slug": "reference/options/choosable",
"parameters": [],
"cases": [],
"methods": [
{
"signature": "label(): string",
"summary": "What a member reads when picking this case."
}
]
}
],
"autocomplete": [
{
"name": "Autocomplete",
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
- [SelectMenu](attributes/select-menu.md) — Declares a class or method as the handler for a select menu choice.
- [ModalSubmit](attributes/modal-submit.md) — Declares a class or method as the handler for a submitted modal.

## Options

- [Choosable](options/choosable.md) — An enum that says how each of its cases should read in Discord.

## Autocomplete

- [Autocomplete](autocomplete/autocomplete.md)
Expand Down
16 changes: 16 additions & 0 deletions docs/reference/options/choosable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<!-- Generated from the source by `composer docs`. Do not edit by hand. -->

# Choosable

An enum that says how each of its cases should read in Discord.

```php
use Tempcord\Interfaces\Choosable;
```

## Methods

### `label(): string`

What a member reads when picking this case.

4 changes: 4 additions & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ parameters:
- tools
excludePaths:
- tests/Fixtures
# Fixtures are not analysed — they exist to be malformed in interesting ways
# — but a test naming one still has to resolve to something.
scanDirectories:
- tests/Fixtures
64 changes: 60 additions & 4 deletions src/Compiler/CommandCompiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use Tempcord\Interfaces\Autocomplete;
use Tempcord\Localization\LocalizationProvider;
use Tempcord\Localization\NullLocalizations;
use ReflectionEnum;
use Tempcord\Interfaces\Choosable;
use Tempest\Reflection\ClassReflector;
use Tempest\Reflection\MethodReflector;
use Tempest\Reflection\ParameterReflector;
Expand Down Expand Up @@ -242,7 +244,7 @@ private function optionsOf(ClassReflector $class, MethodReflector $method, ?stri
isRequired: !$parameter->isOptional(),
autocomplete: $this->autocompleteFor($option, $completers[$name] ?? null),
parameter: $parameter,
choices: $this->choicesOf($option),
choices: $this->choicesOf($option, $parameter),
minValue: $option->minValue,
maxValue: $option->maxValue,
minLength: $option->minLength,
Expand Down Expand Up @@ -312,10 +314,10 @@ private function completersOf(ClassReflector $class): array
*
* @return array<string, string|int|float>
*/
private function choicesOf(Option $option): array
private function choicesOf(Option $option, ParameterReflector $parameter): array
{
if ($option->choices === []) {
return [];
return $this->casesOf($parameter);
}

if (!array_is_list($option->choices)) {
Expand All @@ -331,16 +333,70 @@ private function choicesOf(Option $option): array
return $choices;
}

/**
* Every case of an enum typed option, labelled the way the enum labels
* itself.
*
* Naming them is the whole reason to reach for an enum here rather than a
* string with a hand written choice list that has to be kept in step with
* it. An enum implementing Choosable says how each case reads; otherwise
* the case name is used, which is at least a name someone chose.
*
* @return array<string, string|int>
*/
private function casesOf(ParameterReflector $parameter): array
{
if (!$parameter->getReflection()->hasType()) {
return [];
}

$name = $parameter->getType()->getName();

if (!self::isBackedEnum($name)) {
return [];
}

$choices = [];

foreach ($name::cases() as $case) {
$label = $case instanceof Choosable ? $case->label() : $case->name;
$choices[$label] = $case->value;
}

return $choices;
}

private function typeOf(ParameterReflector $parameter): ApplicationCommandOptionType
{
if (!$parameter->getReflection()->hasType()) {
throw new LogicException('Command option does not have type');
}

return self::OPTION_TYPES[$parameter->getType()->getName()]
$name = $parameter->getType()->getName();

if (self::isBackedEnum($name)) {
/*
* Discord has no enum of its own; a backed enum is a fixed set of
* values, which is a string or an integer option whose choices
* happen to be all of them.
*/
return (new ReflectionEnum($name))->getBackingType()?->getName() === 'int'
? ApplicationCommandOptionType::INTEGER
: ApplicationCommandOptionType::STRING;
}

return self::OPTION_TYPES[$name]
?? throw new LogicException('Command option type not supported');
}

/**
* @phpstan-assert-if-true class-string<BackedEnum> $name
*/
private static function isBackedEnum(string $name): bool
{
return is_subclass_of($name, BackedEnum::class);
}

/**
* Extends a translation key one step down the command tree. Null stays
* null, so a command that declares no key localizes nothing.
Expand Down
32 changes: 32 additions & 0 deletions src/Interfaces/Choosable.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

namespace Tempcord\Interfaces;

/**
* An enum that says how each of its cases should read in Discord.
*
* An enum typed command option offers every case as a choice, and without this
* the case name is what a member sees — which is a PHP identifier, in English,
* and rarely what a bot wants shown. Implement this to name them properly.
*
* enum Platform: string implements Choosable
* {
* case PC = 'PC';
* case PlayStation = 'PS4';
*
* public function label(): string
* {
* return match ($this) {
* self::PC => 'PC',
* self::PlayStation => 'PlayStation',
* };
* }
* }
*/
interface Choosable
{
/**
* What a member reads when picking this case.
*/
public function label(): string;
}
6 changes: 5 additions & 1 deletion src/Runtime/ArgumentResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ public function resolve(HandlerDefinition $handler, CommandInteraction $interact
continue;
}

$supplied[$option->parameter->getName()] = $this->values->resolve($structure, $interaction);
$supplied[$option->parameter->getName()] = $this->values->resolve(
$structure,
$interaction,
$option->parameter,
);
}

$arguments = [];
Expand Down
32 changes: 32 additions & 0 deletions src/Runtime/OptionValueResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
use Tempcord\Discord\Enums\ApplicationCommandOptionType;
use Tempcord\Discord\Interaction\CommandInteraction;
use Tempcord\Discord\Parts\ApplicationCommandInteractionDataOptionStructure;
use BackedEnum;
use RuntimeException;
use Tempest\Reflection\ParameterReflector;
use Throwable;
use function React\Async\await;

Expand All @@ -30,11 +32,16 @@ public function __construct(
public function resolve(
?ApplicationCommandInteractionDataOptionStructure $option,
CommandInteraction $interaction,
?ParameterReflector $parameter = null,
): mixed {
if ($option === null) {
return null;
}

if ($parameter !== null && $this->wantsEnum($parameter)) {
return $this->toEnum($option, $parameter);
}

return match ($option->type) {
ApplicationCommandOptionType::USER => await(
$this->discord->rest->user->get($option->value),
Expand All @@ -55,4 +62,29 @@ public function resolve(
default => $option->value,
};
}

private function wantsEnum(ParameterReflector $parameter): bool
{
return $parameter->getReflection()->hasType()
&& is_subclass_of($parameter->getType()->getName(), BackedEnum::class);
}

/**
* Discord validates a choice against the list it was given, so a value that
* is not a case can only come from a client that made one up. Saying which
* option and which value beats a ValueError from deep inside from().
*/
private function toEnum(
ApplicationCommandInteractionDataOptionStructure $option,
ParameterReflector $parameter,
): BackedEnum {
/** @var class-string<BackedEnum> $enum */
$enum = $parameter->getType()->getName();
$backing = new \ReflectionEnum($enum)->getBackingType()?->getName();
$value = $backing === 'int' ? (int) $option->value : (string) $option->value;

return $enum::tryFrom($value) ?? throw new RuntimeException(
'Option [' . $option->name . '] was sent "' . $option->value . '", which is not a case of ' . $enum,
);
}
}
21 changes: 21 additions & 0 deletions tests/Fixtures/Platform.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Tempcord\Tests\Fixtures;

use Tempcord\Interfaces\Choosable;

enum Platform: string implements Choosable
{
case PC = 'PC';
case PlayStation = 'PS4';
case Xbox = 'X1';

public function label(): string
{
return match ($this) {
self::PC => 'PC',
self::PlayStation => 'PlayStation',
self::Xbox => 'Xbox',
};
}
}
26 changes: 26 additions & 0 deletions tests/Fixtures/PlatformCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

namespace Tempcord\Tests\Fixtures;

use Tempcord\Attributes\Command;
use Tempcord\Attributes\Option;
use Tempcord\Discord\Interaction\CommandInteraction;

#[Command(description: 'Looks a player up.')]
final class PlatformCommand
{
public static ?Platform $platform = null;

public static ?Region $region = null;

public function __invoke(
CommandInteraction $interaction,
#[Option(description: 'Where you play.')]
Platform $platform,
#[Option(description: 'Which region.')]
?Region $region = null,
): void {
self::$platform = $platform;
self::$region = $region;
}
}
12 changes: 12 additions & 0 deletions tests/Fixtures/Region.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

namespace Tempcord\Tests\Fixtures;

/**
* A plain backed enum, which has no say in how its cases read.
*/
enum Region: int
{
case Europe = 1;
case NorthAmerica = 2;
}
Loading
Loading