4 * Parses commit messages (containing relatively freeform text with textual
5 * field labels) into a dictionary of fields.
7 * $parser = id(new DifferentialCommitMessageParser())
8 * ->setLabelMap($label_map)
9 * ->setTitleKey($key_title)
10 * ->setSummaryKey($key_summary);
12 * $fields = $parser->parseCorpus($corpus);
13 * $errors = $parser->getErrors();
15 * This is used by Differential to parse messages entered from the command line.
17 * @task config Configuring the Parser
18 * @task parse Parsing Messages
19 * @task support Support Methods
20 * @task internal Internals
22 final class DifferentialCommitMessageParser
extends Phobject
{
29 private $commitMessageFields;
30 private $raiseMissingFieldErrors = true;
33 public static function newStandardParser(PhabricatorUser
$viewer) {
34 $key_title = DifferentialTitleCommitMessageField
::FIELDKEY
;
35 $key_summary = DifferentialSummaryCommitMessageField
::FIELDKEY
;
37 $field_list = DifferentialCommitMessageField
::newEnabledFields($viewer);
41 ->setCommitMessageFields($field_list)
42 ->setTitleKey($key_title)
43 ->setSummaryKey($key_summary);
47 /* -( Configuring the Parser )--------------------------------------------- */
53 public function setViewer(PhabricatorUser
$viewer) {
54 $this->viewer
= $viewer;
62 public function getViewer() {
70 public function setCommitMessageFields(array $fields) {
71 assert_instances_of($fields, 'DifferentialCommitMessageField');
72 $fields = mpull($fields, null, 'getCommitMessageFieldKey');
73 $this->commitMessageFields
= $fields;
81 public function getCommitMessageFields() {
82 return $this->commitMessageFields
;
89 public function setRaiseMissingFieldErrors($raise) {
90 $this->raiseMissingFieldErrors
= $raise;
98 public function getRaiseMissingFieldErrors() {
99 return $this->raiseMissingFieldErrors
;
106 public function setLabelMap(array $label_map) {
107 $this->labelMap
= $label_map;
115 public function setTitleKey($title_key) {
116 $this->titleKey
= $title_key;
124 public function setSummaryKey($summary_key) {
125 $this->summaryKey
= $summary_key;
130 /* -( Parsing Messages )--------------------------------------------------- */
136 public function parseCorpus($corpus) {
137 $this->errors
= array();
138 $this->xactions
= array();
140 $label_map = $this->getLabelMap();
141 $key_title = $this->titleKey
;
142 $key_summary = $this->summaryKey
;
144 if (!$key_title ||
!$key_summary ||
($label_map === null)) {
147 'Expected %s, %s and %s to be set before parsing a corpus.',
153 $label_regexp = $this->buildLabelRegexp($label_map);
155 // NOTE: We're special casing things here to make the "Title:" label
156 // optional in the message.
161 $lines = trim($corpus);
162 $lines = phutil_split_lines($lines, false);
164 $field_map = array();
165 foreach ($lines as $key => $line) {
166 // We always parse the first line of the message as a title, even if it
167 // contains something we recognize as a field header.
168 if (!isset($seen[$key_title])) {
171 $lines[$key] = trim($line);
172 $seen[$field] = true;
175 if (preg_match($label_regexp, $line, $match)) {
176 $lines[$key] = trim($match['text']);
177 $field = $label_map[self
::normalizeFieldLabel($match['field'])];
178 if (!empty($seen[$field])) {
179 $this->errors
[] = pht(
180 'Field "%s" occurs twice in commit message!',
183 $seen[$field] = true;
187 $field_map[$key] = $field;
191 foreach ($lines as $key => $line) {
192 $fields[$field_map[$key]][] = $line;
195 // This is a piece of special-cased magic which allows you to omit the
196 // field labels for "title" and "summary". If the user enters a large block
197 // of text at the beginning of the commit message with an empty line in it,
198 // treat everything before the blank line as "title" and everything after
200 if (isset($fields[$key_title]) && empty($fields[$key_summary])) {
201 $lines = $fields[$key_title];
202 for ($ii = 0; $ii < count($lines); $ii++
) {
203 if (strlen(trim($lines[$ii])) == 0) {
207 if ($ii != count($lines)) {
208 $fields[$key_title] = array_slice($lines, 0, $ii);
209 $summary = array_slice($lines, $ii);
210 if (strlen(trim(implode("\n", $summary)))) {
211 $fields[$key_summary] = $summary;
216 // Implode all the lines back into chunks of text.
217 foreach ($fields as $name => $lines) {
218 $data = rtrim(implode("\n", $lines));
219 $data = ltrim($data, "\n");
220 $fields[$name] = $data;
223 // This is another piece of special-cased magic which allows you to
224 // enter a ridiculously long title, or just type a big block of stream
225 // of consciousness text, and have some sort of reasonable result conjured
227 if (isset($fields[$key_title])) {
229 $title = $fields[$key_title];
230 $short = id(new PhutilUTF8StringTruncator())
231 ->setMaximumBytes(250)
232 ->setTerminator($terminal)
233 ->truncateString($title);
235 if ($short != $title) {
237 // If we shortened the title, split the rest into the summary, so
238 // we end up with a title like:
240 // Title title tile title title...
242 // ...and a summary like:
244 // ...title title title.
246 // Summary summary summary summary.
248 $summary = idx($fields, $key_summary, '');
249 $offset = strlen($short) - strlen($terminal);
250 $remainder = ltrim(substr($fields[$key_title], $offset));
251 $summary = '...'.$remainder."\n\n".$summary;
252 $summary = rtrim($summary, "\n");
254 $fields[$key_title] = $short;
255 $fields[$key_summary] = $summary;
266 public function parseFields($corpus) {
267 $viewer = $this->getViewer();
268 $text_map = $this->parseCorpus($corpus);
270 $field_map = $this->getCommitMessageFields();
272 $result_map = array();
273 foreach ($text_map as $field_key => $text_value) {
274 $field = idx($field_map, $field_key);
276 // This is a strict error, since we only parse fields which we have
277 // been told are valid. The caller probably handed us an invalid label
281 'Parser emitted a field with key "%s", but no corresponding '.
282 'field definition exists.',
287 $result = $field->parseFieldValue($text_value);
288 $result_map[$field_key] = $result;
291 $xactions = $field->getFieldTransactions($result);
292 foreach ($xactions as $xaction) {
293 $this->xactions
[] = $xaction;
295 } catch (Exception
$ex) {
296 $this->errors
[] = pht(
297 'Error extracting field transactions from "%s": %s',
298 $field->getFieldName(),
301 } catch (DifferentialFieldParseException
$ex) {
302 $this->errors
[] = pht(
303 'Error parsing field "%s": %s',
304 $field->getFieldName(),
310 if ($this->getRaiseMissingFieldErrors()) {
311 foreach ($field_map as $key => $field) {
313 $field->validateFieldValue(idx($result_map, $key));
314 } catch (DifferentialFieldValidationException
$ex) {
315 $this->errors
[] = pht(
316 'Invalid or missing field "%s": %s',
317 $field->getFieldName(),
330 public function getErrors() {
331 return $this->errors
;
338 public function getTransactions() {
339 return $this->xactions
;
343 /* -( Support Methods )---------------------------------------------------- */
349 public static function normalizeFieldLabel($label) {
350 return phutil_utf8_strtolower($label);
354 /* -( Internals )---------------------------------------------------------- */
357 private function getLabelMap() {
358 if ($this->labelMap
=== null) {
359 $field_list = $this->getCommitMessageFields();
361 $label_map = array();
362 foreach ($field_list as $field_key => $field) {
363 $labels = $field->getFieldAliases();
364 $labels[] = $field->getFieldName();
366 foreach ($labels as $label) {
367 $normal_label = self
::normalizeFieldLabel($label);
368 if (!empty($label_map[$normal_label])) {
371 'Field label "%s" is parsed by two custom fields: "%s" and '.
372 '"%s". Each label must be parsed by only one field.',
375 $label_map[$normal_label]));
378 $label_map[$normal_label] = $field_key;
382 $this->labelMap
= $label_map;
385 return $this->labelMap
;
392 private function buildLabelRegexp(array $label_map) {
393 $field_labels = array_keys($label_map);
394 foreach ($field_labels as $key => $label) {
395 $field_labels[$key] = preg_quote($label, '/');
397 $field_labels = implode('|', $field_labels);
399 $field_pattern = '/^(?P<field>'.$field_labels.'):(?P<text>.*)$/i';
401 return $field_pattern;