Completed
Push — 8.x-1.x ( 5d8f74...bc9e07 )
by Frédéric G.
28s queued 11s
created

Undeclared::run()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 4
c 1
b 0
f 1
dl 0
loc 5
rs 10
cc 1
nc 1
nop 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Drupal\qa\Plugin\QaCheck\Dependencies;
6
7
use Drupal\Core\Extension\ModuleExtensionList;
8
use Drupal\qa\Data;
9
use Drupal\qa\Pass;
10
use Drupal\qa\Plugin\QaCheckBase;
11
use Drupal\qa\Plugin\QaCheckInterface;
12
use Drupal\qa\Plugin\QaCheckManager;
13
use Drupal\qa\Result;
14
use PhpParser\Error;
15
use PhpParser\NodeTraverser;
16
use PhpParser\ParserFactory;
17
use Symfony\Component\DependencyInjection\ContainerInterface;
18
19
/**
20
 * Undeclared checks undeclared module dependencies based on function calls.
21
 *
22
 * It only covers:
23
 * - function calls (not method calls),
24
 * - in .module files,
25
 * - to functions located in other module files.
26
 *
27
 * Later versions could go further.
28
 *
29
 * @QaCheck(
30
 *   id = "dependencies.undeclared",
31
 *   label = @Translation("Undeclared dependencies"),
32
 *   details = @Translation("This check finds modules doing cross-module function calls to other modules not declared as dependencies."),
33
 *   usesBatch = false,
34
 *   steps = 1,
35
 * )
36
 */
37
class Undeclared extends QaCheckBase implements QaCheckInterface {
38
39
  const NAME = 'dependencies.undeclared';
40
41
  /**
42
   * The extension.list.modules service.
43
   *
44
   * @var \Drupal\Core\Extension\ExtensionList
45
   */
46
  protected $elm;
47
48
  /**
49
   * A PHP-Parser parser.
50
   *
51
   * @var \PhpParser\Parser
52
   */
53
  protected $parser;
54
55
  /**
56
   * The plugin_manager.qa_check service.
57
   *
58
   * @var \Drupal\qa\Plugin\QaCheckManager
59
   */
60
  protected $qam;
61
62
  /**
63
   * Undeclared constructor.
64
   *
65
   * @param array $configuration
66
   *   The plugin configuration.
67
   * @param string $id
68
   *   The plugin ID.
69
   * @param array $definition
70
   *   The plugin definition.
71
   * @param \Drupal\Core\Extension\ModuleExtensionList $elm
72
   *   The extension.list.module service.
73
   * @param \Drupal\qa\Plugin\QaCheckManager $qam
74
   *   The plugin_manager.qa_check service.
75
   */
76
  public function __construct(
77
    array $configuration,
78
    string $id,
79
    array $definition,
80
    ModuleExtensionList $elm,
81
    QaCheckManager $qam
82
  ) {
83
    parent::__construct($configuration, $id, $definition);
84
    $this->elm = $elm;
85
    $this->qam = $qam;
86
87
    $this->qam->initInternalFunctions();
88
89
    // For the sake of evolution, ignore PHP5-only code.
90
    $this->parser = (new ParserFactory())->create(ParserFactory::ONLY_PHP7);
91
  }
92
93
  /**
94
   * {@inheritdoc}
95
   */
96
  public static function create(
97
    ContainerInterface $container,
98
    array $configuration,
99
    $id,
100
    $definition
101
  ) {
102
    $elm = $container->get('extension.list.module');
103
    assert($elm instanceof ModuleExtensionList);
104
    $qam = $container->get(Data::MANAGER);
105
    assert($qam instanceof QaCheckManager);
106
    return new static($configuration, $id, $definition, $elm, $qam);
107
  }
108
109
  /**
110
   * Build the list of module names regardless of module installation status.
111
   *
112
   * @return array
113
   *   A map of installed status by module name.
114
   */
115
  protected function getModulesToScan(): array {
116
    $list = $this->elm->getList() ?? [];
117
    $list = array_filter($list, function ($ext) {
118
      $isCore = substr($ext->getPath(), 0, 5) === 'core/';
119
      return !$isCore;
120
    });
121
    $list = array_flip(array_keys($list));
122
    $installed = $this->elm->getAllInstalledInfo();
123
    foreach ($list as $module => &$on) {
124
      $on = isset($installed[$module]);
125
    }
126
    return $list;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $list could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
127
  }
128
129
  /**
130
   * Build the list of function calls in a single source file.
131
   *
132
   * @param string $path
133
   *   The absolute path to the module file.
134
   *
135
   * @return array
136
   *   An array of function names.
137
   */
138
  protected function functionCalls(string $path): array {
139
    $code = file_get_contents($path);
140
    try {
141
      $stmts = $this->parser->parse($code);
142
      assert(is_array($stmts));
143
    }
144
    catch (Error $e) {
145
      echo "Skipping ${path} for parse error: " . $e->getMessage();
146
      return [];
147
    }
148
149
    $traverser = new NodeTraverser();
150
    $traverser->addVisitor($visitor = new FunctionCallVisitor());
151
    $traverser->traverse($stmts);
152
    $pad = array_unique($visitor->pad);
153
    // Ignore builtin/extension functions.
154
    $pad = array_filter($pad, function ($name) {
155
      return empty($this->qam->internalFunctions[$name]);
156
    });
157
    return $pad;
158
  }
159
160
  /**
161
   * Build the list of actual module dependencies in all modules based on calls.
162
   *
163
   * @param string $name
164
   *   The name of the module for which to list dependencies.
165
   * @param array $calls
166
   *   The function calls performed by that module.
167
   *
168
   * @return array
169
   *   A map of modules names by module name.
170
   */
171
  protected function moduleActualDependencies(string $name, array $calls): array {
172
    $modules = [];
173
    foreach ($calls as $called) {
174
      try {
175
        $rf = new \ReflectionFunction($called);
176
      }
177
      catch (\ReflectionException $e) {
178
        $modules[] = "(${called})";
179
        continue;
180
      }
181
182
      // Drupal name-based magic.
183
      $module = basename($rf->getFileName(), '.module');
184
      if ($module !== $name) {
185
        $modules[] = $module;
186
      }
187
    }
188
    return $modules;
189
  }
190
191
  /**
192
   * Build the list of module dependencies in all modules based on module info.
193
   *
194
   * @param string $name
195
   *   The name of the module for which to list dependencies.
196
   *
197
   * @return array
198
   *   A map of modules names by module name.
199
   */
200
  protected function moduleDeclaredDependencies(string $name): array {
201
    $info = $this->elm->getExtensionInfo($name);
202
    $deps = $info['dependencies'] ?? [];
203
    $res = [];
204
    foreach ($deps as $dep) {
205
      $ar = explode(":", $dep);
206
      $res[] = array_pop($ar);
207
    }
208
    return $res;
209
  }
210
211
  /**
212
   * Perform the undeclared calls check.
213
   *
214
   * @return \Drupal\qa\Result
215
   *   The result.
216
   */
217
  public function check(): Result {
218
    $modules = $this->getModulesToScan();
219
    $all = $this->elm->getList();
220
    $undeclared = [];
221
    foreach ($modules as $module => $installed) {
222
      $path = $all[$module]->getExtensionPathname();
223
      if (!$installed || empty($path)) {
224
        continue;
225
      }
226
      $calls = $this->functionCalls($path);
227
      $actual = $this->moduleActualDependencies($module, $calls);
228
      $declared = $this->moduleDeclaredDependencies($module);
229
      $missing = array_diff($actual, $declared);
230
      if (!empty($missing)) {
231
        $undeclared[$module] = $missing;
232
      }
233
    }
234
235
    return new Result('function_calls', empty($undeclared), $undeclared);
236
  }
237
238
  /**
239
   * {@inheritdoc}
240
   */
241
  public function run(): Pass {
242
    $pass = parent::run();
243
    $pass->record($this->check());
244
    $pass->life->end();
245
    return $pass;
246
  }
247
248
}
249