1<?php
2
3final class ArcanistConsoleLintRenderer extends ArcanistLintRenderer {
4
5  const RENDERERKEY = 'console';
6
7  private $testableMode;
8
9  public function setTestableMode($testable_mode) {
10    $this->testableMode = $testable_mode;
11    return $this;
12  }
13
14  public function getTestableMode() {
15    return $this->testableMode;
16  }
17
18  public function supportsPatching() {
19    return true;
20  }
21
22  public function renderResultCode($result_code) {
23    if ($result_code == ArcanistLintWorkflow::RESULT_OKAY) {
24      $view = new PhutilConsoleInfo(
25        pht('OKAY'),
26        pht('No lint messages.'));
27      $this->writeOut($view->drawConsoleString());
28    }
29  }
30
31  public function promptForPatch(
32    ArcanistLintResult $result,
33    $old_path,
34    $new_path) {
35
36    if ($old_path === null) {
37      $old_path = '/dev/null';
38    }
39
40    list($err, $stdout) = exec_manual('diff -u %s %s', $old_path, $new_path);
41    $this->writeOut($stdout);
42
43    $prompt = pht(
44      'Apply this patch to %s?',
45      tsprintf('__%s__', $result->getPath()));
46
47    return phutil_console_confirm($prompt, $default_no = false);
48  }
49
50  public function renderLintResult(ArcanistLintResult $result) {
51    $messages = $result->getMessages();
52    $path = $result->getPath();
53    $data = $result->getData();
54
55    $line_map = $this->newOffsetMap($data);
56
57    $text = array();
58    foreach ($messages as $message) {
59      if ($message->isError()) {
60        $color = 'red';
61      } else {
62        $color = 'yellow';
63      }
64
65      $severity = ArcanistLintSeverity::getStringForSeverity(
66        $message->getSeverity());
67      $code = $message->getCode();
68      $name = $message->getName();
69      $description = $message->getDescription();
70
71      if ($message->getOtherLocations()) {
72        $locations = array();
73        foreach ($message->getOtherLocations() as $location) {
74          $locations[] =
75            idx($location, 'path', $path).
76            (!empty($location['line']) ? ":{$location['line']}" : '');
77        }
78        $description .= "\n".pht(
79          'Other locations: %s',
80          implode(', ', $locations));
81      }
82
83      $text[] = phutil_console_format(
84        "  **<bg:{$color}> %s </bg>** (%s) __%s__\n%s\n",
85        $severity,
86        $code,
87        $name,
88        phutil_console_wrap($description, 4));
89
90      if ($message->hasFileContext()) {
91        $text[] = $this->renderContext($message, $data, $line_map);
92      }
93    }
94
95    if ($text) {
96      $prefix = phutil_console_format(
97        "**>>>** %s\n\n\n",
98        pht(
99          'Lint for %s:',
100          phutil_console_format('__%s__', $path)));
101      $this->writeOut($prefix.implode("\n", $text));
102    }
103  }
104
105  protected function renderContext(
106    ArcanistLintMessage $message,
107    $data,
108    array $line_map) {
109
110    $context = 3;
111
112    $message = $message->newTrimmedMessage();
113
114    $original = $message->getOriginalText();
115    $replacement = $message->getReplacementText();
116
117    $line = $message->getLine();
118    $char = $message->getChar();
119
120    $old = $data;
121    $old_lines = phutil_split_lines($old);
122    $old_impact = substr_count($original, "\n") + 1;
123    $start = $line;
124
125    // See PHI1782. If a linter raises a message at a line that does not
126    // exist, just render a warning message.
127
128    // Linters are permitted to raise a warning at the very end of a file.
129    // For example, if a file is 13 lines long, it is valid to raise a message
130    // on line 14 as long as the character position is 1 or unspecified and
131    // there is no "original" text.
132
133    $max_old = count($old_lines);
134
135    $invalid_position = false;
136    if ($start > ($max_old + 1)) {
137      $invalid_position = true;
138    } else if ($start > $max_old) {
139      if (strlen($original)) {
140        $invalid_position = true;
141      } else if ($char !== null && $char !== 1) {
142        $invalid_position = true;
143      }
144    }
145
146    if ($invalid_position) {
147      $warning = $this->renderLine(
148        $start,
149        pht(
150          '(This message was raised at line %s, but the file only has '.
151          '%s line(s).)',
152          new PhutilNumber($start),
153          new PhutilNumber($max_old)),
154        false,
155        '?');
156
157      return $warning."\n\n";
158    }
159
160    if ($message->isPatchable()) {
161      $patch_offset = $line_map[$line] + ($char - 1);
162
163      $new = substr_replace(
164        $old,
165        $replacement,
166        $patch_offset,
167        strlen($original));
168      $new_lines = phutil_split_lines($new);
169
170      // Figure out how many "-" and "+" lines we have by counting the newlines
171      // for the relevant patches. This may overestimate things if we are adding
172      // or removing entire lines, but we'll adjust things below.
173      $new_impact = substr_count($replacement, "\n") + 1;
174
175      // If this is a change on a single line, we'll try to highlight the
176      // changed character range to make it easier to pick out.
177      if ($old_impact === 1 && $new_impact === 1) {
178        $old_lines[$start - 1] = substr_replace(
179          $old_lines[$start - 1],
180          $this->highlightText($original),
181          $char - 1,
182          strlen($original));
183
184        // See T13543. The message may have completely removed this line: for
185        // example, if it trimmed trailing spaces from the end of a file. If
186        // the line no longer exists, don't try to highlight it.
187        if (isset($new_lines[$start - 1])) {
188          $new_lines[$start - 1] = substr_replace(
189            $new_lines[$start - 1],
190            $this->highlightText($replacement),
191            $char - 1,
192            strlen($replacement));
193        }
194      }
195
196      // If lines at the beginning of the changed line range are actually the
197      // same, shrink the range. This happens when a patch just adds a line.
198      do {
199        $old_line = idx($old_lines, $start - 1, null);
200        $new_line = idx($new_lines, $start - 1, null);
201
202        if ($old_line !== $new_line) {
203          break;
204        }
205
206        $start++;
207        $old_impact--;
208        $new_impact--;
209
210        // We can end up here if a patch removes a line which occurs before
211        // another identical line.
212        if ($old_impact <= 0 || $new_impact <= 0) {
213          break;
214        }
215      } while (true);
216
217      // If the lines at the end of the changed line range are actually the
218      // same, shrink the range. This happens when a patch just removes a
219      // line.
220      if ($old_impact > 0 && $new_impact > 0) {
221        do {
222          $old_suffix = idx($old_lines, $start + $old_impact - 2, null);
223          $new_suffix = idx($new_lines, $start + $new_impact - 2, null);
224
225          if ($old_suffix !== $new_suffix) {
226            break;
227          }
228
229          $old_impact--;
230          $new_impact--;
231
232          // We can end up here if a patch removes a line which occurs after
233          // another identical line.
234          if ($old_impact <= 0 || $new_impact <= 0) {
235            break;
236          }
237        } while (true);
238      }
239
240    } else {
241
242      // If we have "original" text and it is contained on a single line,
243      // highlight the affected area. If we don't have any text, we'll mark
244      // the character with a caret (below, in rendering) instead.
245      if ($old_impact == 1 && strlen($original)) {
246        $old_lines[$start - 1] = substr_replace(
247          $old_lines[$start - 1],
248          $this->highlightText($original),
249          $char - 1,
250          strlen($original));
251      }
252
253      $old_impact = 0;
254      $new_impact = 0;
255    }
256
257    $out = array();
258
259    $head = max(1, $start - $context);
260    for ($ii = $head; $ii < $start; $ii++) {
261      $out[] = array(
262        'text' => $old_lines[$ii - 1],
263        'number' => $ii,
264      );
265    }
266
267    for ($ii = $start; $ii < $start + $old_impact; $ii++) {
268      $out[] = array(
269        'text' => $old_lines[$ii - 1],
270        'number' => $ii,
271        'type' => '-',
272        'chevron' => ($ii == $start),
273      );
274    }
275
276    for ($ii = $start; $ii < $start + $new_impact; $ii++) {
277      // If the patch was at the end of the file and ends with a newline, we
278      // won't have an actual entry in the array for the last line, even though
279      // we want to show it in the diff.
280      $out[] = array(
281        'text' => idx($new_lines, $ii - 1, ''),
282        'type' => '+',
283        'chevron' => ($ii == $start),
284      );
285    }
286
287    $cursor = $start + $old_impact;
288    $foot = min(count($old_lines), $cursor + $context);
289    for ($ii = $cursor; $ii <= $foot; $ii++) {
290      $out[] = array(
291        'text' => $old_lines[$ii - 1],
292        'number' => $ii,
293        'chevron' => ($ii == $cursor),
294      );
295    }
296
297    $result = array();
298
299    $seen_chevron = false;
300    foreach ($out as $spec) {
301      if ($seen_chevron) {
302        $chevron = false;
303      } else {
304        $chevron = !empty($spec['chevron']);
305        if ($chevron) {
306          $seen_chevron = true;
307        }
308      }
309
310      // If the line doesn't actually end in a newline, add one so the layout
311      // doesn't mess up. This can happen when the last line of the old file
312      // didn't have a newline at the end.
313      $text = $spec['text'];
314      if (!preg_match('/\n\z/', $spec['text'])) {
315        $text .= "\n";
316      }
317
318      $result[] = $this->renderLine(
319        idx($spec, 'number'),
320        $text,
321        $chevron,
322        idx($spec, 'type'));
323
324      // If this is just a message and does not have a patch, put a little
325      // caret underneath the line to point out where the issue is.
326      if ($chevron) {
327        if (!$message->isPatchable() && !strlen($original)) {
328          $result[] = $this->renderCaret($char)."\n";
329        }
330      }
331    }
332
333    return implode('', $result);
334  }
335
336  private function renderCaret($pos) {
337    return str_repeat(' ', 16 + $pos).'^';
338  }
339
340  protected function renderLine($line, $data, $chevron = false, $diff = null) {
341    $chevron = $chevron ? '>>>' : '';
342    return sprintf(
343      '    %3s %1s %6s %s',
344      $chevron,
345      $diff,
346      $line,
347      $data);
348  }
349
350  private function newOffsetMap($data) {
351    $lines = phutil_split_lines($data);
352
353    $line_map = array();
354
355    $number = 1;
356    $offset = 0;
357    foreach ($lines as $line) {
358      $line_map[$number] = $offset;
359      $number++;
360      $offset += strlen($line);
361    }
362
363    // If the last line ends in a newline, add a virtual offset for the final
364    // line with no characters on it. This allows lint messages to target the
365    // last line of the file at character 1.
366    if ($lines) {
367      if (preg_match('/\n\z/', $line)) {
368        $line_map[$number] = $offset;
369      }
370    }
371
372    return $line_map;
373  }
374
375  private function highlightText($text) {
376    if ($this->getTestableMode()) {
377      return '>'.$text.'<';
378    } else {
379      return (string)tsprintf('##%s##', $text);
380    }
381  }
382
383}
384