@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
at recaptime-dev/main 405 lines 11 kB view raw
1<?php 2 3/** 4 * Parses commit messages (containing relatively freeform text with textual 5 * field labels) into a dictionary of fields. 6 * 7 * $parser = id(new DifferentialCommitMessageParser()) 8 * ->setLabelMap($label_map) 9 * ->setTitleKey($key_title) 10 * ->setSummaryKey($key_summary); 11 * 12 * $fields = $parser->parseCorpus($corpus); 13 * $errors = $parser->getErrors(); 14 * 15 * This is used by Differential to parse messages entered from the command line. 16 * 17 * @task config Configuring the Parser 18 * @task parse Parsing Messages 19 * @task support Support Methods 20 * @task internal Internals 21 */ 22final class DifferentialCommitMessageParser extends Phobject { 23 24 private $viewer; 25 private $labelMap; 26 private $titleKey; 27 private $summaryKey; 28 private $errors; 29 private $commitMessageFields; 30 private $raiseMissingFieldErrors = true; 31 private $xactions; 32 33 public static function newStandardParser(PhabricatorUser $viewer) { 34 $key_title = DifferentialTitleCommitMessageField::FIELDKEY; 35 $key_summary = DifferentialSummaryCommitMessageField::FIELDKEY; 36 37 $field_list = DifferentialCommitMessageField::newEnabledFields($viewer); 38 39 return id(new self()) 40 ->setViewer($viewer) 41 ->setCommitMessageFields($field_list) 42 ->setTitleKey($key_title) 43 ->setSummaryKey($key_summary); 44 } 45 46 47/* -( Configuring the Parser )--------------------------------------------- */ 48 49 50 /** 51 * @task config 52 */ 53 public function setViewer(PhabricatorUser $viewer) { 54 $this->viewer = $viewer; 55 return $this; 56 } 57 58 59 /** 60 * @task config 61 */ 62 public function getViewer() { 63 return $this->viewer; 64 } 65 66 67 /** 68 * @param array<DifferentialCommitMessageField> $fields 69 * @task config 70 */ 71 public function setCommitMessageFields(array $fields) { 72 assert_instances_of($fields, DifferentialCommitMessageField::class); 73 $fields = mpull($fields, null, 'getCommitMessageFieldKey'); 74 $this->commitMessageFields = $fields; 75 return $this; 76 } 77 78 79 /** 80 * @task config 81 */ 82 public function getCommitMessageFields() { 83 return $this->commitMessageFields; 84 } 85 86 87 /** 88 * @task config 89 */ 90 public function setRaiseMissingFieldErrors($raise) { 91 $this->raiseMissingFieldErrors = $raise; 92 return $this; 93 } 94 95 96 /** 97 * @task config 98 */ 99 public function getRaiseMissingFieldErrors() { 100 return $this->raiseMissingFieldErrors; 101 } 102 103 104 /** 105 * @task config 106 */ 107 public function setLabelMap(array $label_map) { 108 $this->labelMap = $label_map; 109 return $this; 110 } 111 112 113 /** 114 * @task config 115 */ 116 public function setTitleKey($title_key) { 117 $this->titleKey = $title_key; 118 return $this; 119 } 120 121 122 /** 123 * @task config 124 */ 125 public function setSummaryKey($summary_key) { 126 $this->summaryKey = $summary_key; 127 return $this; 128 } 129 130 131/* -( Parsing Messages )--------------------------------------------------- */ 132 133 134 /** 135 * @task parse 136 */ 137 public function parseCorpus($corpus) { 138 $this->errors = array(); 139 $this->xactions = array(); 140 141 $label_map = $this->getLabelMap(); 142 $key_title = $this->titleKey; 143 $key_summary = $this->summaryKey; 144 145 if (!$key_title || !$key_summary || ($label_map === null)) { 146 throw new Exception( 147 pht( 148 'Expected %s, %s and %s to be set before parsing a corpus.', 149 'labelMap', 150 'summaryKey', 151 'titleKey')); 152 } 153 154 $label_regexp = $this->buildLabelRegexp($label_map); 155 156 // NOTE: We're special casing things here to make the "Title:" label 157 // optional in the message. 158 $field = $key_title; 159 160 $seen = array(); 161 162 $lines = $corpus === null ? $corpus : trim($corpus); 163 $lines = phutil_split_lines($lines, false); 164 165 $field_map = array(); 166 foreach ($lines as $key => $line) { 167 // We always parse the first line of the message as a title, even if it 168 // contains something we recognize as a field header. 169 if (!isset($seen[$key_title])) { 170 $field = $key_title; 171 172 $lines[$key] = trim($line); 173 $seen[$field] = true; 174 } else { 175 $match = null; 176 if (preg_match($label_regexp, $line, $match)) { 177 $lines[$key] = trim($match['text']); 178 $field = $label_map[self::normalizeFieldLabel($match['field'])]; 179 if (!empty($seen[$field])) { 180 $this->errors[] = pht( 181 'Field "%s" occurs twice in commit message!', 182 $match['field']); 183 } 184 $seen[$field] = true; 185 } 186 } 187 188 $field_map[$key] = $field; 189 } 190 191 $fields = array(); 192 foreach ($lines as $key => $line) { 193 $fields[$field_map[$key]][] = $line; 194 } 195 196 // This is a piece of special-cased magic which allows you to omit the 197 // field labels for "title" and "summary". If the user enters a large block 198 // of text at the beginning of the commit message with an empty line in it, 199 // treat everything before the blank line as "title" and everything after 200 // as "summary". 201 if (isset($fields[$key_title]) && empty($fields[$key_summary])) { 202 $lines = $fields[$key_title]; 203 for ($ii = 0; $ii < count($lines); $ii++) { 204 if (strlen(trim($lines[$ii])) == 0) { 205 break; 206 } 207 } 208 if ($ii != count($lines)) { 209 $fields[$key_title] = array_slice($lines, 0, $ii); 210 $summary = array_slice($lines, $ii); 211 if (strlen(trim(implode("\n", $summary)))) { 212 $fields[$key_summary] = $summary; 213 } 214 } 215 } 216 217 // Implode all the lines back into chunks of text. 218 foreach ($fields as $name => $lines) { 219 $data = rtrim(implode("\n", $lines)); 220 $data = ltrim($data, "\n"); 221 $fields[$name] = $data; 222 } 223 224 // This is another piece of special-cased magic which allows you to 225 // enter a ridiculously long title, or just type a big block of stream 226 // of consciousness text, and have some sort of reasonable result conjured 227 // from it. 228 if (isset($fields[$key_title])) { 229 $terminal = '...'; 230 $title = $fields[$key_title]; 231 $short = id(new PhutilUTF8StringTruncator()) 232 ->setMaximumBytes(250) 233 ->setTerminator($terminal) 234 ->truncateString($title); 235 236 if ($short != $title) { 237 238 // If we shortened the title, split the rest into the summary, so 239 // we end up with a title like: 240 // 241 // Title title tile title title... 242 // 243 // ...and a summary like: 244 // 245 // ...title title title. 246 // 247 // Summary summary summary summary. 248 249 $summary = idx($fields, $key_summary, ''); 250 $offset = strlen($short) - strlen($terminal); 251 $remainder = ltrim(substr($fields[$key_title], $offset)); 252 $summary = '...'.$remainder."\n\n".$summary; 253 $summary = rtrim($summary, "\n"); 254 255 $fields[$key_title] = $short; 256 $fields[$key_summary] = $summary; 257 } 258 } 259 260 return $fields; 261 } 262 263 264 /** 265 * @task parse 266 */ 267 public function parseFields($corpus) { 268 $viewer = $this->getViewer(); 269 $text_map = $this->parseCorpus($corpus); 270 271 $field_map = $this->getCommitMessageFields(); 272 273 $result_map = array(); 274 foreach ($text_map as $field_key => $text_value) { 275 $field = idx($field_map, $field_key); 276 if (!$field) { 277 // This is a strict error, since we only parse fields which we have 278 // been told are valid. The caller probably handed us an invalid label 279 // map. 280 throw new Exception( 281 pht( 282 'Parser emitted a field with key "%s", but no corresponding '. 283 'field definition exists.', 284 $field_key)); 285 } 286 287 try { 288 $result = $field->parseFieldValue($text_value); 289 $result_map[$field_key] = $result; 290 291 try { 292 $xactions = $field->getFieldTransactions($result); 293 foreach ($xactions as $xaction) { 294 $this->xactions[] = $xaction; 295 } 296 } catch (Exception $ex) { 297 $this->errors[] = pht( 298 'Error extracting field transactions from "%s": %s', 299 $field->getFieldName(), 300 $ex->getMessage()); 301 } 302 } catch (DifferentialFieldParseException $ex) { 303 $this->errors[] = pht( 304 'Error parsing field "%s": %s', 305 $field->getFieldName(), 306 $ex->getMessage()); 307 } 308 309 } 310 311 if ($this->getRaiseMissingFieldErrors()) { 312 foreach ($field_map as $key => $field) { 313 try { 314 $field->validateFieldValue(idx($result_map, $key)); 315 } catch (DifferentialFieldValidationException $ex) { 316 $this->errors[] = pht( 317 'Invalid or missing field "%s": %s', 318 $field->getFieldName(), 319 $ex->getMessage()); 320 } 321 } 322 } 323 324 return $result_map; 325 } 326 327 328 /** 329 * @task parse 330 */ 331 public function getErrors() { 332 return $this->errors; 333 } 334 335 336 /** 337 * @task parse 338 */ 339 public function getTransactions() { 340 return $this->xactions; 341 } 342 343 344/* -( Support Methods )---------------------------------------------------- */ 345 346 347 /** 348 * @task support 349 */ 350 public static function normalizeFieldLabel($label) { 351 return phutil_utf8_strtolower($label); 352 } 353 354 355/* -( Internals )---------------------------------------------------------- */ 356 357 358 private function getLabelMap() { 359 if ($this->labelMap === null) { 360 $field_list = $this->getCommitMessageFields(); 361 362 $label_map = array(); 363 foreach ($field_list as $field_key => $field) { 364 $labels = $field->getFieldAliases(); 365 $labels[] = $field->getFieldName(); 366 367 foreach ($labels as $label) { 368 $normal_label = self::normalizeFieldLabel($label); 369 if (!empty($label_map[$normal_label])) { 370 throw new Exception( 371 pht( 372 'Field label "%s" is parsed by two custom fields: "%s" and '. 373 '"%s". Each label must be parsed by only one field.', 374 $label, 375 $field_key, 376 $label_map[$normal_label])); 377 } 378 379 $label_map[$normal_label] = $field_key; 380 } 381 } 382 383 $this->labelMap = $label_map; 384 } 385 386 return $this->labelMap; 387 } 388 389 390 /** 391 * @task internal 392 */ 393 private function buildLabelRegexp(array $label_map) { 394 $field_labels = array_keys($label_map); 395 foreach ($field_labels as $key => $label) { 396 $field_labels[$key] = preg_quote($label, '/'); 397 } 398 $field_labels = implode('|', $field_labels); 399 400 $field_pattern = '/^(?P<field>'.$field_labels.'):(?P<text>.*)$/i'; 401 402 return $field_pattern; 403 } 404 405}