Completed
Pull Request — master (#127)
by Greg
01:49
created

AnnotatedCommandFactory::isCommandMethod()   B

Complexity

Conditions 5
Paths 5

Size

Total Lines 22
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 22
rs 8.6737
cc 5
eloc 10
nc 5
nop 2
1
<?php
2
namespace Consolidation\AnnotatedCommand;
3
4
use Consolidation\AnnotatedCommand\Cache\CacheWrapper;
5
use Consolidation\AnnotatedCommand\Cache\NullCache;
6
use Consolidation\AnnotatedCommand\Cache\SimpleCacheInterface;
7
use Consolidation\AnnotatedCommand\Hooks\HookManager;
8
use Consolidation\AnnotatedCommand\Options\AutomaticOptionsProviderInterface;
9
use Consolidation\AnnotatedCommand\Parser\CommandInfo;
10
use Consolidation\AnnotatedCommand\Parser\CommandInfoDeserializer;
11
use Consolidation\AnnotatedCommand\Parser\CommandInfoSerializer;
12
use Consolidation\OutputFormatters\Options\FormatterOptions;
13
use Symfony\Component\Console\Command\Command;
14
use Symfony\Component\Console\Input\InputInterface;
15
use Symfony\Component\Console\Output\OutputInterface;
16
17
/**
18
 * The AnnotatedCommandFactory creates commands for your application.
19
 * Use with a Dependency Injection Container and the CommandFactory.
20
 * Alternately, use the CommandFileDiscovery to find commandfiles, and
21
 * then use AnnotatedCommandFactory::createCommandsFromClass() to create
22
 * commands.  See the README for more information.
23
 *
24
 * @package Consolidation\AnnotatedCommand
25
 */
26
class AnnotatedCommandFactory implements AutomaticOptionsProviderInterface
27
{
28
    /** var CommandProcessor */
29
    protected $commandProcessor;
30
31
    /** var CommandCreationListenerInterface[] */
32
    protected $listeners = [];
33
34
    /** var AutomaticOptionsProvider[] */
35
    protected $automaticOptionsProviderList = [];
36
37
    /** var boolean */
38
    protected $includeAllPublicMethods = true;
39
40
    /** var CommandInfoAltererInterface */
41
    protected $commandInfoAlterers = [];
42
43
    /** var SimpleCacheInterface */
44
    protected $dataStore;
45
46
    public function __construct()
47
    {
48
        $this->dataStore = new NullCache();
49
        $this->commandProcessor = new CommandProcessor(new HookManager());
50
        $this->addAutomaticOptionProvider($this);
51
    }
52
53
    public function setCommandProcessor(CommandProcessor $commandProcessor)
54
    {
55
        $this->commandProcessor = $commandProcessor;
56
        return $this;
57
    }
58
59
    /**
60
     * @return CommandProcessor
61
     */
62
    public function commandProcessor()
63
    {
64
        return $this->commandProcessor;
65
    }
66
67
    /**
68
     * Set the 'include all public methods flag'. If true (the default), then
69
     * every public method of each commandFile will be used to create commands.
70
     * If it is false, then only those public methods annotated with @command
71
     * or @name (deprecated) will be used to create commands.
72
     */
73
    public function setIncludeAllPublicMethods($includeAllPublicMethods)
74
    {
75
        $this->includeAllPublicMethods = $includeAllPublicMethods;
76
        return $this;
77
    }
78
79
    public function getIncludeAllPublicMethods()
80
    {
81
        return $this->includeAllPublicMethods;
82
    }
83
84
    /**
85
     * @return HookManager
86
     */
87
    public function hookManager()
88
    {
89
        return $this->commandProcessor()->hookManager();
90
    }
91
92
    /**
93
     * Add a listener that is notified immediately before the command
94
     * factory creates commands from a commandFile instance.  This
95
     * listener can use this opportunity to do more setup for the commandFile,
96
     * and so on.
97
     *
98
     * @param CommandCreationListenerInterface $listener
99
     */
100
    public function addListener(CommandCreationListenerInterface $listener)
101
    {
102
        $this->listeners[] = $listener;
103
        return $this;
104
    }
105
106
    /**
107
     * Add a listener that's just a simple 'callable'.
108
     * @param callable $listener
109
     */
110
    public function addListernerCallback(callable $listener)
111
    {
112
        $this->addListener(new CommandCreationListener($listener));
113
        return $this;
114
    }
115
116
    /**
117
     * Call all command creation listeners
118
     *
119
     * @param object $commandFileInstance
120
     */
121
    protected function notify($commandFileInstance)
122
    {
123
        foreach ($this->listeners as $listener) {
124
            $listener->notifyCommandFileAdded($commandFileInstance);
125
        }
126
    }
127
128
    public function addAutomaticOptionProvider(AutomaticOptionsProviderInterface $optionsProvider)
129
    {
130
        $this->automaticOptionsProviderList[] = $optionsProvider;
131
    }
132
133
    public function addCommandInfoAlterer(CommandInfoAltererInterface $alterer)
134
    {
135
        $this->commandInfoAlterers[] = $alterer;
136
    }
137
138
    /**
139
     * n.b. This registers all hooks from the commandfile instance as a side-effect.
140
     */
141
    public function createCommandsFromClass($commandFileInstance, $includeAllPublicMethods = null)
142
    {
143
        // Deprecated: avoid using the $includeAllPublicMethods in favor of the setIncludeAllPublicMethods() accessor.
144
        if (!isset($includeAllPublicMethods)) {
145
            $includeAllPublicMethods = $this->getIncludeAllPublicMethods();
146
        }
147
        $this->notify($commandFileInstance);
148
        $commandInfoList = $this->getCommandInfoListFromClass($commandFileInstance);
149
        $this->registerCommandHooksFromClassInfo($commandInfoList, $commandFileInstance);
150
        return $this->createCommandsFromClassInfo($commandInfoList, $commandFileInstance, $includeAllPublicMethods);
151
    }
152
153
    public function getCommandInfoListFromClass($commandFileInstance)
154
    {
155
        $cachedCommandInfoList = $this->getCommandInfoListFromCache($commandFileInstance);
156
        $commandInfoList = $this->createCommandInfoListFromClass($commandFileInstance, $cachedCommandInfoList);
157
        if (!empty($commandInfoList)) {
158
            $cachedCommandInfoList = array_merge($commandInfoList, $cachedCommandInfoList);
159
            $this->storeCommandInfoListInCache($commandFileInstance, $cachedCommandInfoList);
160
        }
161
        return $cachedCommandInfoList;
162
    }
163
164
    protected function storeCommandInfoListInCache($commandFileInstance, $commandInfoList)
165
    {
166
        if (!$this->hasDataStore()) {
167
            return;
168
        }
169
        $cache_data = [];
170
        $serializer = new CommandInfoSerializer();
171
        foreach ($commandInfoList as $i => $commandInfo) {
172
            $cache_data[$i] = $serializer->serialize($commandInfo);
173
        }
174
        $className = get_class($commandFileInstance);
175
        $this->getDataStore()->set($className, $cache_data);
176
    }
177
178
    /**
179
     * Get the command info list from the cache
180
     *
181
     * @param mixed $commandFileInstance
182
     * @return array
183
     */
184
    protected function getCommandInfoListFromCache($commandFileInstance)
185
    {
186
        $commandInfoList = [];
187
        $className = get_class($commandFileInstance);
188
        if (!$this->getDataStore()->has($className)) {
189
            return [];
190
        }
191
        $deserializer = new CommandInfoDeserializer();
192
193
        $cache_data = $this->getDataStore()->get($className);
194
        foreach ($cache_data as $i => $data) {
195
            if (CommandInfoDeserializer::isValidSerializedData((array)$data)) {
196
                $commandInfoList[$i] = $deserializer->deserialize((array)$data);
197
            }
198
        }
199
        return $commandInfoList;
200
    }
201
202
    /**
203
     * Check to see if this factory has a cache datastore.
204
     * @return boolean
205
     */
206
    public function hasDataStore()
207
    {
208
        return !($this->dataStore instanceof NullCache);
209
    }
210
211
    /**
212
     * Set a cache datastore for this factory. Any object with 'set' and
213
     * 'get' methods is acceptable. The key is the classname being cached,
214
     * and the value is a nested associative array of strings.
215
     *
216
     * TODO: Typehint this to SimpleCacheInterface
217
     *
218
     * This is not done currently to allow clients to use a generic cache
219
     * store that does not itself depend on the annotated-command library.
220
     *
221
     * @param Mixed $dataStore
222
     * @return type
223
     */
224
    public function setDataStore($dataStore)
225
    {
226
        if (!($dataStore instanceof SimpleCacheInterface)) {
227
            $dataStore = new CacheWrapper($dataStore);
228
        }
229
        $this->dataStore = $dataStore;
230
        return $this;
231
    }
232
233
    /**
234
     * Get the data store attached to this factory.
235
     */
236
    public function getDataStore()
237
    {
238
        return $this->dataStore;
239
    }
240
241
    protected function createCommandInfoListFromClass($classNameOrInstance, $cachedCommandInfoList)
242
    {
243
        $commandInfoList = [];
244
245
        // Ignore special functions, such as __construct and __call, which
246
        // can never be commands.
247
        $commandMethodNames = array_filter(
248
            get_class_methods($classNameOrInstance) ?: [],
249
            function ($m) use ($classNameOrInstance) {
250
                $reflectionMethod = new \ReflectionMethod($classNameOrInstance, $m);
251
                return !$reflectionMethod->isStatic() && !preg_match('#^_#', $m);
252
            }
253
        );
254
255
        foreach ($commandMethodNames as $commandMethodName) {
256
            if (!array_key_exists($commandMethodName, $cachedCommandInfoList)) {
257
                $commandInfo = CommandInfo::create($classNameOrInstance, $commandMethodName);
258
                if (!static::isCommandOrHookMethod($commandInfo, $this->getIncludeAllPublicMethods())) {
259
                    $commandInfo->invalidate();
260
                }
261
                $commandInfoList[$commandMethodName] =  $commandInfo;
262
            }
263
        }
264
265
        return $commandInfoList;
266
    }
267
268
    public function createCommandInfo($classNameOrInstance, $commandMethodName)
269
    {
270
        return CommandInfo::create($classNameOrInstance, $commandMethodName);
271
    }
272
273
    public function createCommandsFromClassInfo($commandInfoList, $commandFileInstance, $includeAllPublicMethods = null)
274
    {
275
        // Deprecated: avoid using the $includeAllPublicMethods in favor of the setIncludeAllPublicMethods() accessor.
276
        if (!isset($includeAllPublicMethods)) {
277
            $includeAllPublicMethods = $this->getIncludeAllPublicMethods();
278
        }
279
        return $this->createSelectedCommandsFromClassInfo(
280
            $commandInfoList,
281
            $commandFileInstance,
282
            function ($commandInfo) use ($includeAllPublicMethods) {
283
                return static::isCommandMethod($commandInfo, $includeAllPublicMethods);
284
            }
285
        );
286
    }
287
288
    public function createSelectedCommandsFromClassInfo($commandInfoList, $commandFileInstance, callable $commandSelector)
289
    {
290
        $commandInfoList = $this->filterCommandInfoList($commandInfoList, $commandSelector);
291
        return array_map(
292
            function ($commandInfo) use ($commandFileInstance) {
293
                return $this->createCommand($commandInfo, $commandFileInstance);
294
            },
295
            $commandInfoList
296
        );
297
    }
298
299
    protected function filterCommandInfoList($commandInfoList, callable $commandSelector)
300
    {
301
        return array_filter($commandInfoList, $commandSelector);
302
    }
303
304
    public static function isCommandOrHookMethod($commandInfo, $includeAllPublicMethods)
305
    {
306
        return static::isHookMethod($commandInfo) || static::isCommandMethod($commandInfo, $includeAllPublicMethods);
307
    }
308
309
    public static function isHookMethod($commandInfo)
310
    {
311
        return $commandInfo->hasAnnotation('hook');
312
    }
313
314
    public static function isCommandMethod($commandInfo, $includeAllPublicMethods)
315
    {
316
        // Ignore everything labeled @hook
317
        if (static::isHookMethod($commandInfo)) {
318
            return false;
319
        }
320
        // Include everything labeled @command
321
        if ($commandInfo->hasAnnotation('command')) {
322
            return true;
323
        }
324
        // Skip anything that has an invalid name.
325
        if (empty($commandInfo->getName())) {
326
            return false;
327
        }
328
        // Skip anything named like an accessor ('get' or 'set')
329
        if (preg_match('#^(get[A-Z]|set[A-Z])#', $commandInfo->getMethodName())) {
330
            return false;
331
        }
332
333
        // Default to the setting of 'include all public methods'.
334
        return $includeAllPublicMethods;
335
    }
336
337
    public function registerCommandHooksFromClassInfo($commandInfoList, $commandFileInstance)
338
    {
339
        foreach ($commandInfoList as $commandInfo) {
340
            if (static::isHookMethod($commandInfo)) {
341
                $this->registerCommandHook($commandInfo, $commandFileInstance);
342
            }
343
        }
344
    }
345
346
    /**
347
     * Register a command hook given the CommandInfo for a method.
348
     *
349
     * The hook format is:
350
     *
351
     *   @hook type name type
352
     *
353
     * For example, the pre-validate hook for the core:init command is:
354
     *
355
     *   @hook pre-validate core:init
356
     *
357
     * If no command name is provided, then this hook will affect every
358
     * command that is defined in the same file.
359
     *
360
     * If no hook is provided, then we will presume that ALTER_RESULT
361
     * is intended.
362
     *
363
     * @param CommandInfo $commandInfo Information about the command hook method.
364
     * @param object $commandFileInstance An instance of the CommandFile class.
365
     */
366
    public function registerCommandHook(CommandInfo $commandInfo, $commandFileInstance)
367
    {
368
        // Ignore if the command info has no @hook
369
        if (!static::isHookMethod($commandInfo)) {
370
            return;
371
        }
372
        $hookData = $commandInfo->getAnnotation('hook');
373
        $hook = $this->getNthWord($hookData, 0, HookManager::ALTER_RESULT);
374
        $commandName = $this->getNthWord($hookData, 1);
375
376
        // Register the hook
377
        $callback = [$commandFileInstance, $commandInfo->getMethodName()];
378
        $this->commandProcessor()->hookManager()->add($callback, $hook, $commandName);
379
380
        // If the hook has options, then also register the commandInfo
381
        // with the hook manager, so that we can add options and such to
382
        // the commands they hook.
383
        if (!$commandInfo->options()->isEmpty()) {
384
            $this->commandProcessor()->hookManager()->recordHookOptions($commandInfo, $commandName);
385
        }
386
    }
387
388
    protected function getNthWord($string, $n, $default = '', $delimiter = ' ')
389
    {
390
        $words = explode($delimiter, $string);
391
        if (!empty($words[$n])) {
392
            return $words[$n];
393
        }
394
        return $default;
395
    }
396
397
    public function createCommand(CommandInfo $commandInfo, $commandFileInstance)
398
    {
399
        $this->alterCommandInfo($commandInfo, $commandFileInstance);
400
        $command = new AnnotatedCommand($commandInfo->getName());
401
        $commandCallback = [$commandFileInstance, $commandInfo->getMethodName()];
402
        $command->setCommandCallback($commandCallback);
403
        $command->setCommandProcessor($this->commandProcessor);
404
        $command->setCommandInfo($commandInfo);
405
        $automaticOptions = $this->callAutomaticOptionsProviders($commandInfo);
406
        $command->setCommandOptions($commandInfo, $automaticOptions);
407
        // Annotation commands are never bootstrap-aware, but for completeness
408
        // we will notify on every created command, as some clients may wish to
409
        // use this notification for some other purpose.
410
        $this->notify($command);
411
        return $command;
412
    }
413
414
    /**
415
     * Give plugins an opportunity to update the commandInfo
416
     */
417
    public function alterCommandInfo(CommandInfo $commandInfo, $commandFileInstance)
418
    {
419
        foreach ($this->commandInfoAlterers as $alterer) {
420
            $alterer->alterCommandInfo($commandInfo, $commandFileInstance);
421
        }
422
    }
423
424
    /**
425
     * Get the options that are implied by annotations, e.g. @fields implies
426
     * that there should be a --fields and a --format option.
427
     *
428
     * @return InputOption[]
429
     */
430
    public function callAutomaticOptionsProviders(CommandInfo $commandInfo)
431
    {
432
        $automaticOptions = [];
433
        foreach ($this->automaticOptionsProviderList as $automaticOptionsProvider) {
434
            $automaticOptions += $automaticOptionsProvider->automaticOptions($commandInfo);
435
        }
436
        return $automaticOptions;
437
    }
438
439
    /**
440
     * Get the options that are implied by annotations, e.g. @fields implies
441
     * that there should be a --fields and a --format option.
442
     *
443
     * @return InputOption[]
444
     */
445
    public function automaticOptions(CommandInfo $commandInfo)
446
    {
447
        $automaticOptions = [];
448
        $formatManager = $this->commandProcessor()->formatterManager();
449
        if ($formatManager) {
450
            $annotationData = $commandInfo->getAnnotations()->getArrayCopy();
451
            $formatterOptions = new FormatterOptions($annotationData);
452
            $dataType = $commandInfo->getReturnType();
453
            $automaticOptions = $formatManager->automaticOptions($formatterOptions, $dataType);
454
        }
455
        return $automaticOptions;
456
    }
457
}
458