Skip to content

Commit 9ef19cf

Browse files
feat: fully working command system with autocompletion (excludes entity targets) (#15)
1 parent 9b6b115 commit 9ef19cf

46 files changed

Lines changed: 6706 additions & 3352 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/plugins/plugins.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ plugins:
2121
# NODE_ENV: development
2222
- id: example-typescript
2323
name: Example TypeScript Plugin
24-
command: "node"
25-
args: ["../examples/plugins/typescript/dist/index.js"]
24+
#command: "node"
25+
#args: ["../examples/plugins/typescript/dist/index.js"]
26+
command: "npm"
27+
args: ["run", "dev"]
28+
work_dir: "../examples/plugins/typescript"
2629
env:
2730
NODE_ENV: production
2831
- id: example-php

examples/plugins/php/composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"autoload": {
1414
"psr-4": {
1515
"Df\\": "../../../proto/generated/php/Df/",
16-
"Dragonfly\\PluginLib\\": "lib/"
16+
"Dragonfly\\PluginLib\\": "lib/",
17+
"ExamplePhp\\": "src/"
1718
}
1819
},
1920
"config": {
Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
<?php
2+
3+
namespace Dragonfly\PluginLib\Commands;
4+
5+
use Dragonfly\PluginLib\Events\EventContext;
6+
use ReflectionClass;
7+
use ReflectionNamedType;
8+
use ReflectionProperty;
9+
use ReflectionType;
10+
use RuntimeException;
11+
12+
/**
13+
* Base command class with reflection-based argument parsing.
14+
*
15+
* Supported parameter types:
16+
* - int, float, bool, string
17+
* - Varargs (must be last)
18+
* - Optional (wrapper; optional params must be last, may be multiple)
19+
*
20+
* Define parameters as public properties on a subclass, in the order they
21+
* should be parsed. Example:
22+
*
23+
* class TpCommand extends Command {
24+
* protected string $name = 'tpc';
25+
* protected string $description = 'Teleport to coordinates';
26+
* public float $x;
27+
* /** @var Optional<float> *\/
28+
* public Optional $y;
29+
* public float $z;
30+
* public function execute(CommandSender $sender, EventContext $ctx): void { ... }
31+
* }
32+
*/
33+
abstract class Command {
34+
// Metadata
35+
protected string $name = '';
36+
protected string $description = '';
37+
/** @var string[] */
38+
protected array $aliases = [];
39+
40+
abstract public function execute(CommandSender $sender, EventContext $ctx): void;
41+
42+
public function getName(): string {
43+
return $this->name;
44+
}
45+
46+
public function getDescription(): string {
47+
return $this->description;
48+
}
49+
50+
/**
51+
* @return string[]
52+
*/
53+
public function getAliases(): array {
54+
return $this->aliases;
55+
}
56+
57+
/**
58+
* Parse command arguments. Returns true on success, false on usage error.
59+
*
60+
* @param string[] $rawArgs
61+
*/
62+
public function parseArgs(array $rawArgs): bool {
63+
try {
64+
$this->validateSignature();
65+
} catch (\Throwable) {
66+
return false;
67+
}
68+
$schema = $this->inspectParameters();
69+
$ref = new ReflectionClass($this);
70+
$props = $this->getCommandProperties($ref);
71+
$propMap = [];
72+
foreach ($props as $p) {
73+
$p->setAccessible(true);
74+
$propMap[$p->getName()] = $p;
75+
}
76+
77+
$argIndex = 0;
78+
$argCount = count($rawArgs);
79+
$paramCount = count($schema);
80+
81+
foreach ($schema as $idx => $param) {
82+
$name = $param['name'];
83+
$type = $param['type']; // int|float|bool|string|varargs
84+
$optional = !empty($param['optional']);
85+
86+
$prop = $propMap[$name] ?? null;
87+
if (!$prop) {
88+
return false;
89+
}
90+
91+
if ($type === 'varargs') {
92+
if ($idx !== $paramCount - 1) {
93+
return false;
94+
}
95+
$remaining = array_slice($rawArgs, $argIndex);
96+
$prop->setValue($this, new Varargs(implode(' ', $remaining)));
97+
return true;
98+
}
99+
100+
if ($argIndex >= $argCount) {
101+
if ($optional) {
102+
if ($this->getTypeName($prop->getType()) === Optional::class) {
103+
$prop->setValue($this, new Optional());
104+
continue;
105+
}
106+
return false;
107+
}
108+
return false;
109+
}
110+
111+
$parsed = $this->parseTypedValue($rawArgs[$argIndex], $type);
112+
if ($parsed === null && $type !== 'string') {
113+
return false;
114+
}
115+
116+
if ($this->getTypeName($prop->getType()) === Optional::class) {
117+
$opt = new Optional();
118+
$opt->set($parsed);
119+
$prop->setValue($this, $opt);
120+
} else {
121+
$prop->setValue($this, $parsed);
122+
}
123+
$argIndex++;
124+
}
125+
if ($argIndex < $argCount) {
126+
return false;
127+
}
128+
return true;
129+
}
130+
131+
/**
132+
* Validate parameter ordering rules:
133+
* - Optional parameters may only appear at the end (can be multiple).
134+
* - Varargs must be the final parameter.
135+
*/
136+
public function validateSignature(): void {
137+
$ref = new ReflectionClass($this);
138+
$props = $this->getCommandProperties($ref);
139+
140+
$seenOptional = false;
141+
foreach ($props as $index => $prop) {
142+
$typeName = $this->getTypeName($prop->getType());
143+
if ($typeName === Varargs::class) {
144+
if ($index !== count($props) - 1) {
145+
throw new RuntimeException('Varargs must be the last parameter.');
146+
}
147+
continue;
148+
}
149+
if ($seenOptional && $typeName !== Optional::class) {
150+
throw new RuntimeException('Optional parameters must be at the end.');
151+
}
152+
if ($typeName === Optional::class) {
153+
$seenOptional = true;
154+
}
155+
}
156+
}
157+
158+
/**
159+
* Generate a human-friendly usage string.
160+
*/
161+
public function generateUsage(): string {
162+
$parts = ['/' . $this->name];
163+
foreach ($this->inspectParameters() as $p) {
164+
$name = $p['name'];
165+
$type = $p['type'];
166+
$optional = !empty($p['optional']);
167+
if ($type === 'varargs') {
168+
$parts[] = '<' . $name . '...>';
169+
} elseif ($optional) {
170+
$parts[] = '[' . $name . ']';
171+
} else {
172+
$parts[] = '<' . $name . '>';
173+
}
174+
}
175+
return implode(' ', $parts);
176+
}
177+
178+
/**
179+
* Export parameter specification for transport to the host (Go) side.
180+
* Format: list of ['name' => string, 'type' => string, 'optional' => bool]
181+
* Types: int|float|bool|string|varargs
182+
*
183+
* @return array<int, array{name:string,type:string,optional?:bool}>
184+
*/
185+
public function serializeParamSpec(): array {
186+
return $this->inspectParameters();
187+
}
188+
189+
/**
190+
* @return ReflectionProperty[]
191+
*/
192+
private function getCommandProperties(ReflectionClass $ref): array {
193+
$props = $ref->getProperties(ReflectionProperty::IS_PUBLIC);
194+
$filtered = [];
195+
foreach ($props as $p) {
196+
$n = $p->getName();
197+
if ($n === 'name' || $n === 'description' || $n === 'aliases') {
198+
continue;
199+
}
200+
$filtered[] = $p;
201+
}
202+
return $filtered;
203+
}
204+
205+
private function getTypeName(?ReflectionType $type): ?string {
206+
if ($type instanceof ReflectionNamedType) {
207+
return $type->getName();
208+
}
209+
return null;
210+
}
211+
212+
private function parseTypedValue(string $arg, ?string $typeName): mixed {
213+
return match ($typeName) {
214+
'int' => filter_var($arg, FILTER_VALIDATE_INT),
215+
'float' => filter_var($arg, FILTER_VALIDATE_FLOAT),
216+
'bool' => $this->parseBool($arg),
217+
null, 'string' => $arg,
218+
default => null,
219+
};
220+
}
221+
222+
private function parseBool(string $arg): ?bool {
223+
$v = strtolower($arg);
224+
return match ($v) {
225+
'true', '1', 'yes', 'on' => true,
226+
'false', '0', 'no', 'off' => false,
227+
default => null,
228+
};
229+
}
230+
231+
/**
232+
* Build a normalized parameter schema from the command's public properties.
233+
* @return array<int, array{name:string,type:string,optional?:bool}>
234+
*/
235+
private function inspectParameters(): array {
236+
$ref = new ReflectionClass($this);
237+
$props = $this->getCommandProperties($ref);
238+
$out = [];
239+
foreach ($props as $prop) {
240+
$name = $prop->getName();
241+
$typeName = $this->getTypeName($prop->getType());
242+
if ($typeName === Varargs::class) {
243+
$out[] = ['name' => $name, 'type' => 'varargs'];
244+
break;
245+
}
246+
if ($typeName === Optional::class) {
247+
$t = $this->getOptionalWrappedType($prop);
248+
$out[] = ['name' => $name, 'type' => $t, 'optional' => true];
249+
continue;
250+
}
251+
$mapped = match ($typeName) {
252+
'int' => 'int',
253+
'float', 'double' => 'float',
254+
'bool' => 'bool',
255+
default => 'string',
256+
};
257+
$out[] = ['name' => $name, 'type' => $mapped];
258+
}
259+
return $out;
260+
}
261+
262+
/**
263+
* Convenience: attach enum values to a parameter in a schema.
264+
*
265+
* @param array<int, array{name:string,type:string,optional?:bool,enum_values?:array<int,string>}> $schema
266+
* @param string $paramName
267+
* @param string[] $values
268+
* @return array
269+
*/
270+
protected function withEnum(array $schema, string $paramName, array $values): array {
271+
foreach ($schema as &$p) {
272+
if ($p['name'] === $paramName) {
273+
$p['enum_values'] = array_values($values);
274+
$p['type'] = 'enum';
275+
break;
276+
}
277+
}
278+
return $schema;
279+
}
280+
281+
/**
282+
* Convenience: enum names from a class' constants, with optional excludes.
283+
*
284+
* @param string $class Fully-qualified class name
285+
* @param string[] $excludeNames
286+
* @return string[]
287+
*/
288+
protected function enumNamesFromClass(string $class, array $excludeNames = []): array {
289+
$names = array_keys((new \ReflectionClass($class))->getConstants());
290+
if (!empty($excludeNames)) {
291+
$names = array_values(array_filter($names, fn ($n) => !in_array($n, $excludeNames, true)));
292+
}
293+
return $names;
294+
}
295+
296+
/**
297+
* Attempt to infer the wrapped type for Optional<T> from @var docblock.
298+
*/
299+
private function getOptionalWrappedType(ReflectionProperty $prop): string {
300+
$doc = $prop->getDocComment() ?: '';
301+
if (preg_match('/@var\s+Optional<\s*([A-Za-z_][A-Za-z0-9_]*)\s*>/i', $doc, $m)) {
302+
$t = strtolower($m[1]);
303+
return match ($t) {
304+
'int' => 'int',
305+
'float', 'double' => 'float',
306+
'bool', 'boolean' => 'bool',
307+
'string' => 'string',
308+
default => 'string',
309+
};
310+
}
311+
// Default to string if not annotated.
312+
return 'string';
313+
}
314+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Dragonfly\PluginLib\Commands;
4+
5+
class CommandSender {
6+
public function __construct(
7+
public string $uuid,
8+
public string $name,
9+
) {}
10+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php
2+
3+
namespace Dragonfly\PluginLib\Commands;
4+
5+
/**
6+
* Optional parameter wrapper. Optional parameters must come after required ones,
7+
* and may not be followed by non-optional parameters.
8+
*/
9+
class Optional {
10+
private mixed $value = null;
11+
private bool $present = false;
12+
13+
public function set(mixed $value): void {
14+
$this->value = $value;
15+
$this->present = true;
16+
}
17+
18+
public function isPresent(): bool {
19+
return $this->present;
20+
}
21+
22+
public function get(): mixed {
23+
return $this->value;
24+
}
25+
26+
public function getOr(mixed $default): mixed {
27+
return $this->present ? $this->value : $default;
28+
}
29+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace Dragonfly\PluginLib\Commands;
4+
5+
/**
6+
* Varargs consumes all remaining command arguments as a single string.
7+
* Must be the last parameter in a command class.
8+
*/
9+
class Varargs {
10+
public function __construct(public string $value) {}
11+
12+
public function __toString(): string {
13+
return $this->value;
14+
}
15+
}

0 commit comments

Comments
 (0)