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