@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
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}