Generate file attachment transactions for explicit Remarkup attachments on common...
[phabricator.git] / src / applications / differential / parser / DifferentialCommitMessageParser.php
blob3a73428e4ba4f0d389f06cc912d21be830a4eb1a
1 <?php
3 /**
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 {
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;
33 public static function newStandardParser(PhabricatorUser $viewer) {
34 $key_title = DifferentialTitleCommitMessageField::FIELDKEY;
35 $key_summary = DifferentialSummaryCommitMessageField::FIELDKEY;
37 $field_list = DifferentialCommitMessageField::newEnabledFields($viewer);
39 return id(new self())
40 ->setViewer($viewer)
41 ->setCommitMessageFields($field_list)
42 ->setTitleKey($key_title)
43 ->setSummaryKey($key_summary);
47 /* -( Configuring the Parser )--------------------------------------------- */
50 /**
51 * @task config
53 public function setViewer(PhabricatorUser $viewer) {
54 $this->viewer = $viewer;
55 return $this;
59 /**
60 * @task config
62 public function getViewer() {
63 return $this->viewer;
67 /**
68 * @task config
70 public function setCommitMessageFields(array $fields) {
71 assert_instances_of($fields, 'DifferentialCommitMessageField');
72 $fields = mpull($fields, null, 'getCommitMessageFieldKey');
73 $this->commitMessageFields = $fields;
74 return $this;
78 /**
79 * @task config
81 public function getCommitMessageFields() {
82 return $this->commitMessageFields;
86 /**
87 * @task config
89 public function setRaiseMissingFieldErrors($raise) {
90 $this->raiseMissingFieldErrors = $raise;
91 return $this;
95 /**
96 * @task config
98 public function getRaiseMissingFieldErrors() {
99 return $this->raiseMissingFieldErrors;
104 * @task config
106 public function setLabelMap(array $label_map) {
107 $this->labelMap = $label_map;
108 return $this;
113 * @task config
115 public function setTitleKey($title_key) {
116 $this->titleKey = $title_key;
117 return $this;
122 * @task config
124 public function setSummaryKey($summary_key) {
125 $this->summaryKey = $summary_key;
126 return $this;
130 /* -( Parsing Messages )--------------------------------------------------- */
134 * @task parse
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)) {
145 throw new Exception(
146 pht(
147 'Expected %s, %s and %s to be set before parsing a corpus.',
148 'labelMap',
149 'summaryKey',
150 'titleKey'));
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.
157 $field = $key_title;
159 $seen = array();
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])) {
169 $field = $key_title;
171 $lines[$key] = trim($line);
172 $seen[$field] = true;
173 } else {
174 $match = null;
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!',
181 $match['field']);
183 $seen[$field] = true;
187 $field_map[$key] = $field;
190 $fields = array();
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
199 // as "summary".
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) {
204 break;
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
226 // from it.
227 if (isset($fields[$key_title])) {
228 $terminal = '...';
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;
259 return $fields;
264 * @task parse
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);
275 if (!$field) {
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
278 // map.
279 throw new Exception(
280 pht(
281 'Parser emitted a field with key "%s", but no corresponding '.
282 'field definition exists.',
283 $field_key));
286 try {
287 $result = $field->parseFieldValue($text_value);
288 $result_map[$field_key] = $result;
290 try {
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(),
299 $ex->getMessage());
301 } catch (DifferentialFieldParseException $ex) {
302 $this->errors[] = pht(
303 'Error parsing field "%s": %s',
304 $field->getFieldName(),
305 $ex->getMessage());
310 if ($this->getRaiseMissingFieldErrors()) {
311 foreach ($field_map as $key => $field) {
312 try {
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(),
318 $ex->getMessage());
323 return $result_map;
328 * @task parse
330 public function getErrors() {
331 return $this->errors;
336 * @task parse
338 public function getTransactions() {
339 return $this->xactions;
343 /* -( Support Methods )---------------------------------------------------- */
347 * @task support
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])) {
369 throw new Exception(
370 pht(
371 'Field label "%s" is parsed by two custom fields: "%s" and '.
372 '"%s". Each label must be parsed by only one field.',
373 $label,
374 $field_key,
375 $label_map[$normal_label]));
378 $label_map[$normal_label] = $field_key;
382 $this->labelMap = $label_map;
385 return $this->labelMap;
390 * @task internal
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;