~ubuntu-branches/ubuntu/wily/phabricator/wily

« back to all changes in this revision

Viewing changes to src/lint/linter/ArcanistExternalLinter.php

  • Committer: Package Import Robot
  • Author(s): Richard Sellam
  • Date: 2014-11-01 23:20:06 UTC
  • mto: This revision was merged to the branch mainline in revision 4.
  • Revision ID: package-import@ubuntu.com-20141101232006-mvlnp0cil67tsboe
Tags: upstream-0~git20141101/arcanist
Import upstream version 0~git20141101, component arcanist

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
<?php
 
2
 
 
3
/**
 
4
 * Base class for linters which operate by invoking an external program and
 
5
 * parsing results.
 
6
 *
 
7
 * @task bin      Interpreters, Binaries and Flags
 
8
 * @task parse    Parsing Linter Output
 
9
 * @task exec     Executing the Linter
 
10
 */
 
11
abstract class ArcanistExternalLinter extends ArcanistFutureLinter {
 
12
 
 
13
  private $bin;
 
14
  private $interpreter;
 
15
  private $flags;
 
16
 
 
17
 
 
18
/* -(  Interpreters, Binaries and Flags  )----------------------------------- */
 
19
 
 
20
  /**
 
21
   * Return the default binary name or binary path where the external linter
 
22
   * lives. This can either be a binary name which is expected to be installed
 
23
   * in PATH (like "jshint"), or a relative path from the project root
 
24
   * (like "resources/support/bin/linter") or an absolute path.
 
25
   *
 
26
   * If the binary needs an interpreter (like "python" or "node"), you should
 
27
   * also override @{method:shouldUseInterpreter} and provide the interpreter
 
28
   * in @{method:getDefaultInterpreter}.
 
29
   *
 
30
   * @return string Default binary to execute.
 
31
   * @task bin
 
32
   */
 
33
  abstract public function getDefaultBinary();
 
34
 
 
35
  /**
 
36
   * Return a human-readable string describing how to install the linter. This
 
37
   * is normally something like "Install such-and-such by running `npm install
 
38
   * -g such-and-such`.", but will differ from linter to linter.
 
39
   *
 
40
   * @return string Human readable install instructions
 
41
   * @task bin
 
42
   */
 
43
  abstract public function getInstallInstructions();
 
44
 
 
45
  /**
 
46
   * Return true to continue when the external linter exits with an error code.
 
47
   * By default, linters which exit with an error code are assumed to have
 
48
   * failed. However, some linters exit with a specific code to indicate that
 
49
   * lint messages were detected.
 
50
   *
 
51
   * If the linter sometimes raises errors during normal operation, override
 
52
   * this method and return true so execution continues when it exits with
 
53
   * a nonzero status.
 
54
   *
 
55
   * @param bool  Return true to continue on nonzero error code.
 
56
   * @task bin
 
57
   */
 
58
  public function shouldExpectCommandErrors() {
 
59
    return false;
 
60
  }
 
61
 
 
62
  /**
 
63
   * Return true to indicate that the external linter can read input from
 
64
   * stdin, rather than requiring a file. If this mode is supported, it is
 
65
   * slightly more flexible and may perform better, and is thus preferable.
 
66
   *
 
67
   * To send data over stdin instead of via a command line parameter, override
 
68
   * this method and return true. If the linter also needs a command line
 
69
   * flag (like `--stdin` or `-`), override
 
70
   * @{method:getReadDataFromStdinFilename} to provide it.
 
71
   *
 
72
   * For example, linters are normally invoked something like this:
 
73
   *
 
74
   *   $ linter file.js
 
75
   *
 
76
   * If you override this method, invocation will be more similar to this:
 
77
   *
 
78
   *   $ linter < file.js
 
79
   *
 
80
   * If you additionally override @{method:getReadDataFromStdinFilename} to
 
81
   * return `"-"`, invocation will be similar to this:
 
82
   *
 
83
   *   $ linter - < file.js
 
84
   *
 
85
   * @return bool True to send data over stdin.
 
86
   * @task bin
 
87
   */
 
88
  public function supportsReadDataFromStdin() {
 
89
    return false;
 
90
  }
 
91
 
 
92
  /**
 
93
   * If the linter can read data over stdin, override
 
94
   * @{method:supportsReadDataFromStdin} and then optionally override this
 
95
   * method to provide any required arguments (like `-` or `--stdin`). See
 
96
   * that method for discussion.
 
97
   *
 
98
   * @return string|null  Additional arguments required by the linter when
 
99
   *                      operating in stdin mode.
 
100
   * @task bin
 
101
   */
 
102
  public function getReadDataFromStdinFilename() {
 
103
    return null;
 
104
  }
 
105
 
 
106
  /**
 
107
   * Provide mandatory, non-overridable flags to the linter. Generally these
 
108
   * are format flags, like `--format=xml`, which must always be given for
 
109
   * the output to be usable.
 
110
   *
 
111
   * Flags which are not mandatory should be provided in
 
112
   * @{method:getDefaultFlags} instead.
 
113
   *
 
114
   * @return list<string>  Mandatory flags, like `"--format=xml"`.
 
115
   * @task bin
 
116
   */
 
117
  protected function getMandatoryFlags() {
 
118
    return array();
 
119
  }
 
120
 
 
121
  /**
 
122
   * Provide default, overridable flags to the linter. Generally these are
 
123
   * configuration flags which affect behavior but aren't critical. Flags
 
124
   * which are required should be provided in @{method:getMandatoryFlags}
 
125
   * instead.
 
126
   *
 
127
   * Default flags can be overridden with @{method:setFlags}.
 
128
   *
 
129
   * @return list<string>  Overridable default flags.
 
130
   * @task bin
 
131
   */
 
132
  protected function getDefaultFlags() {
 
133
    return array();
 
134
  }
 
135
 
 
136
  /**
 
137
   * Override default flags with custom flags. If not overridden, flags provided
 
138
   * by @{method:getDefaultFlags} are used.
 
139
   *
 
140
   * @param list<string> New flags.
 
141
   * @return this
 
142
   * @task bin
 
143
   */
 
144
  final public function setFlags($flags) {
 
145
    $this->flags = (array)$flags;
 
146
    return $this;
 
147
  }
 
148
 
 
149
  /**
 
150
   * Return the binary or script to execute. This method synthesizes defaults
 
151
   * and configuration. You can override the binary with @{method:setBinary}.
 
152
   *
 
153
   * @return string Binary to execute.
 
154
   * @task bin
 
155
   */
 
156
  final public function getBinary() {
 
157
    return coalesce($this->bin, $this->getDefaultBinary());
 
158
  }
 
159
 
 
160
  /**
 
161
   * Override the default binary with a new one.
 
162
   *
 
163
   * @param string  New binary.
 
164
   * @return this
 
165
   * @task bin
 
166
   */
 
167
  final public function setBinary($bin) {
 
168
    $this->bin = $bin;
 
169
    return $this;
 
170
  }
 
171
 
 
172
  /**
 
173
   * Return true if this linter should use an interpreter (like "python" or
 
174
   * "node") in addition to the script.
 
175
   *
 
176
   * After overriding this method to return `true`, override
 
177
   * @{method:getDefaultInterpreter} to set a default.
 
178
   *
 
179
   * @return bool True to use an interpreter.
 
180
   * @task bin
 
181
   */
 
182
  public function shouldUseInterpreter() {
 
183
    return false;
 
184
  }
 
185
 
 
186
  /**
 
187
   * Return the default interpreter, like "python" or "node". This method is
 
188
   * only invoked if @{method:shouldUseInterpreter} has been overridden to
 
189
   * return `true`.
 
190
   *
 
191
   * @return string Default interpreter.
 
192
   * @task bin
 
193
   */
 
194
  public function getDefaultInterpreter() {
 
195
    throw new Exception('Incomplete implementation!');
 
196
  }
 
197
 
 
198
  /**
 
199
   * Get the effective interpreter. This method synthesizes configuration and
 
200
   * defaults.
 
201
   *
 
202
   * @return string Effective interpreter.
 
203
   * @task bin
 
204
   */
 
205
  final public function getInterpreter() {
 
206
    return coalesce($this->interpreter, $this->getDefaultInterpreter());
 
207
  }
 
208
 
 
209
  /**
 
210
   * Set the interpreter, overriding any default.
 
211
   *
 
212
   * @param string New interpreter.
 
213
   * @return this
 
214
   * @task bin
 
215
   */
 
216
  final public function setInterpreter($interpreter) {
 
217
    $this->interpreter = $interpreter;
 
218
    return $this;
 
219
  }
 
220
 
 
221
 
 
222
/* -(  Parsing Linter Output  )---------------------------------------------- */
 
223
 
 
224
  /**
 
225
   * Parse the output of the external lint program into objects of class
 
226
   * @{class:ArcanistLintMessage} which `arc` can consume. Generally, this
 
227
   * means examining the output and converting each warning or error into a
 
228
   * message.
 
229
   *
 
230
   * If parsing fails, returning `false` will cause the caller to throw an
 
231
   * appropriate exception. (You can also throw a more specific exception if
 
232
   * you're able to detect a more specific condition.) Otherwise, return a list
 
233
   * of messages.
 
234
   *
 
235
   * @param  string   Path to the file being linted.
 
236
   * @param  int      Exit code of the linter.
 
237
   * @param  string   Stdout of the linter.
 
238
   * @param  string   Stderr of the linter.
 
239
   * @return list<ArcanistLintMessage>|false  List of lint messages, or false
 
240
   *                                          to indicate parser failure.
 
241
   * @task parse
 
242
   */
 
243
  abstract protected function parseLinterOutput($path, $err, $stdout, $stderr);
 
244
 
 
245
 
 
246
/* -(  Executing the Linter  )----------------------------------------------- */
 
247
 
 
248
  /**
 
249
   * Check that the binary and interpreter (if applicable) exist, and throw
 
250
   * an exception with a message about how to install them if they do not.
 
251
   *
 
252
   * @return void
 
253
   */
 
254
  final public function checkBinaryConfiguration() {
 
255
    $interpreter = null;
 
256
    if ($this->shouldUseInterpreter()) {
 
257
      $interpreter = $this->getInterpreter();
 
258
    }
 
259
 
 
260
    $binary = $this->getBinary();
 
261
 
 
262
    // NOTE: If we have an interpreter, we don't require the script to be
 
263
    // executable (so we just check that the path exists). Otherwise, the
 
264
    // binary must be executable.
 
265
 
 
266
    if ($interpreter) {
 
267
      if (!Filesystem::binaryExists($interpreter)) {
 
268
        throw new ArcanistUsageException(
 
269
          pht(
 
270
            'Unable to locate interpreter "%s" to run linter %s. You may '.
 
271
            'need to install the interpreter, or adjust your linter '.
 
272
            'configuration.'.
 
273
            "\nTO INSTALL: %s",
 
274
            $interpreter,
 
275
            get_class($this),
 
276
            $this->getInstallInstructions()));
 
277
      }
 
278
      if (!Filesystem::pathExists($binary)) {
 
279
        throw new ArcanistUsageException(
 
280
          pht(
 
281
            'Unable to locate script "%s" to run linter %s. You may need '.
 
282
            'to install the script, or adjust your linter configuration. '.
 
283
            "\nTO INSTALL: %s",
 
284
            $binary,
 
285
            get_class($this),
 
286
            $this->getInstallInstructions()));
 
287
      }
 
288
    } else {
 
289
      if (!Filesystem::binaryExists($binary)) {
 
290
        throw new ArcanistUsageException(
 
291
          pht(
 
292
            'Unable to locate binary "%s" to run linter %s. You may need '.
 
293
            'to install the binary, or adjust your linter configuration. '.
 
294
            "\nTO INSTALL: %s",
 
295
            $binary,
 
296
            get_class($this),
 
297
            $this->getInstallInstructions()));
 
298
      }
 
299
    }
 
300
  }
 
301
 
 
302
  /**
 
303
   * Get the composed executable command, including the interpreter and binary
 
304
   * but without flags or paths. This can be used to execute `--version`
 
305
   * commands.
 
306
   *
 
307
   * @return string Command to execute the raw linter.
 
308
   * @task exec
 
309
   */
 
310
  final protected function getExecutableCommand() {
 
311
    $this->checkBinaryConfiguration();
 
312
 
 
313
    $interpreter = null;
 
314
    if ($this->shouldUseInterpreter()) {
 
315
      $interpreter = $this->getInterpreter();
 
316
    }
 
317
 
 
318
    $binary = $this->getBinary();
 
319
 
 
320
    if ($interpreter) {
 
321
      $bin = csprintf('%s %s', $interpreter, $binary);
 
322
    } else {
 
323
      $bin = csprintf('%s', $binary);
 
324
    }
 
325
 
 
326
    return $bin;
 
327
  }
 
328
 
 
329
  /**
 
330
   * Get the composed flags for the executable, including both mandatory and
 
331
   * configured flags.
 
332
   *
 
333
   * @return list<string> Composed flags.
 
334
   * @task exec
 
335
   */
 
336
  final protected function getCommandFlags() {
 
337
    $mandatory_flags = $this->getMandatoryFlags();
 
338
    if (!is_array($mandatory_flags)) {
 
339
      phutil_deprecated(
 
340
        'String support for flags.', 'You should use list<string> instead.');
 
341
      $mandatory_flags = (array) $mandatory_flags;
 
342
    }
 
343
 
 
344
    $flags = nonempty($this->flags, $this->getDefaultFlags());
 
345
    if (!is_array($flags)) {
 
346
      phutil_deprecated(
 
347
        'String support for flags.', 'You should use list<string> instead.');
 
348
      $flags = (array) $flags;
 
349
    }
 
350
 
 
351
    return array_merge($mandatory_flags, $flags);
 
352
  }
 
353
 
 
354
  public function getCacheVersion() {
 
355
    $version = $this->getVersion();
 
356
 
 
357
    if ($version) {
 
358
      return $version.'-'.json_encode($this->getCommandFlags());
 
359
    } else {
 
360
      // Either we failed to parse the version number or the `getVersion`
 
361
      // function hasn't been implemented.
 
362
      return json_encode($this->getCommandFlags());
 
363
    }
 
364
  }
 
365
 
 
366
  /**
 
367
   * Prepare the path to be added to the command string.
 
368
   *
 
369
   * This method is expected to return an already escaped string.
 
370
   *
 
371
   * @param string Path to the file being linted
 
372
   * @return string The command-ready file argument
 
373
   */
 
374
  protected function getPathArgumentForLinterFuture($path) {
 
375
    return csprintf('%s', $path);
 
376
  }
 
377
 
 
378
  final protected function buildFutures(array $paths) {
 
379
    $executable = $this->getExecutableCommand();
 
380
 
 
381
    $bin = csprintf('%C %Ls', $executable, $this->getCommandFlags());
 
382
 
 
383
    $futures = array();
 
384
    foreach ($paths as $path) {
 
385
      if ($this->supportsReadDataFromStdin()) {
 
386
        $future = new ExecFuture(
 
387
          '%C %C',
 
388
          $bin,
 
389
          $this->getReadDataFromStdinFilename());
 
390
        $future->write($this->getEngine()->loadData($path));
 
391
      } else {
 
392
        // TODO: In commit hook mode, we need to do more handling here.
 
393
        $disk_path = $this->getEngine()->getFilePathOnDisk($path);
 
394
        $path_argument = $this->getPathArgumentForLinterFuture($disk_path);
 
395
        $future = new ExecFuture('%C %C', $bin, $path_argument);
 
396
      }
 
397
 
 
398
      $future->setCWD($this->getEngine()->getWorkingCopy()->getProjectRoot());
 
399
      $futures[$path] = $future;
 
400
    }
 
401
 
 
402
    return $futures;
 
403
  }
 
404
 
 
405
  final protected function resolveFuture($path, Future $future) {
 
406
    list($err, $stdout, $stderr) = $future->resolve();
 
407
    if ($err && !$this->shouldExpectCommandErrors()) {
 
408
      $future->resolvex();
 
409
    }
 
410
 
 
411
    $messages = $this->parseLinterOutput($path, $err, $stdout, $stderr);
 
412
 
 
413
    if ($messages === false) {
 
414
      if ($err) {
 
415
        $future->resolvex();
 
416
      } else {
 
417
        throw new Exception(
 
418
          "Linter failed to parse output!\n\n{$stdout}\n\n{$stderr}");
 
419
      }
 
420
    }
 
421
 
 
422
    foreach ($messages as $message) {
 
423
      $this->addLintMessage($message);
 
424
    }
 
425
  }
 
426
 
 
427
  public function getLinterConfigurationOptions() {
 
428
    $options = array(
 
429
      'bin' => array(
 
430
        'type' => 'optional string | list<string>',
 
431
        'help' => pht(
 
432
          'Specify a string (or list of strings) identifying the binary '.
 
433
          'which should be invoked to execute this linter. This overrides '.
 
434
          'the default binary. If you provide a list of possible binaries, '.
 
435
          'the first one which exists will be used.'),
 
436
      ),
 
437
      'flags' => array(
 
438
        'type' => 'optional list<string>',
 
439
        'help' => pht(
 
440
          'Provide a list of additional flags to pass to the linter on the '.
 
441
          'command line.'),
 
442
      ),
 
443
    );
 
444
 
 
445
    if ($this->shouldUseInterpreter()) {
 
446
      $options['interpreter'] = array(
 
447
        'type' => 'optional string | list<string>',
 
448
        'help' => pht(
 
449
          'Specify a string (or list of strings) identifying the interpreter '.
 
450
          'which should be used to invoke the linter binary. If you provide '.
 
451
          'a list of possible interpreters, the first one that exists '.
 
452
          'will be used.'),
 
453
      );
 
454
    }
 
455
 
 
456
    return $options + parent::getLinterConfigurationOptions();
 
457
  }
 
458
 
 
459
  public function setLinterConfigurationValue($key, $value) {
 
460
    switch ($key) {
 
461
      case 'interpreter':
 
462
        $working_copy = $this->getEngine()->getWorkingCopy();
 
463
        $root = $working_copy->getProjectRoot();
 
464
 
 
465
        foreach ((array)$value as $path) {
 
466
          if (Filesystem::binaryExists($path)) {
 
467
            $this->setInterpreter($path);
 
468
            return;
 
469
          }
 
470
 
 
471
          $path = Filesystem::resolvePath($path, $root);
 
472
 
 
473
          if (Filesystem::binaryExists($path)) {
 
474
            $this->setInterpreter($path);
 
475
            return;
 
476
          }
 
477
        }
 
478
 
 
479
        throw new Exception(
 
480
          pht('None of the configured interpreters can be located.'));
 
481
      case 'bin':
 
482
        $is_script = $this->shouldUseInterpreter();
 
483
 
 
484
        $working_copy = $this->getEngine()->getWorkingCopy();
 
485
        $root = $working_copy->getProjectRoot();
 
486
 
 
487
        foreach ((array)$value as $path) {
 
488
          if (!$is_script && Filesystem::binaryExists($path)) {
 
489
            $this->setBinary($path);
 
490
            return;
 
491
          }
 
492
 
 
493
          $path = Filesystem::resolvePath($path, $root);
 
494
          if ((!$is_script && Filesystem::binaryExists($path)) ||
 
495
              ($is_script && Filesystem::pathExists($path))) {
 
496
            $this->setBinary($path);
 
497
            return;
 
498
          }
 
499
        }
 
500
 
 
501
        throw new Exception(
 
502
          pht('None of the configured binaries can be located.'));
 
503
      case 'flags':
 
504
        if (!is_array($value)) {
 
505
          phutil_deprecated(
 
506
            'String support for flags.',
 
507
            'You should use list<string> instead.');
 
508
          $value = (array) $value;
 
509
        }
 
510
        $this->setFlags($value);
 
511
        return;
 
512
    }
 
513
 
 
514
    return parent::setLinterConfigurationValue($key, $value);
 
515
  }
 
516
 
 
517
  /**
 
518
   * Map a configuration lint code to an `arc` lint code. Primarily, this is
 
519
   * intended for validation, but can also be used to normalize case or
 
520
   * otherwise be more permissive in accepted inputs.
 
521
   *
 
522
   * If the code is not recognized, you should throw an exception.
 
523
   *
 
524
   * @param string  Code specified in configuration.
 
525
   * @return string  Normalized code to use in severity map.
 
526
   */
 
527
  protected function getLintCodeFromLinterConfigurationKey($code) {
 
528
    return $code;
 
529
  }
 
530
 
 
531
}