Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
freebsd
GitHub Repository: freebsd/phabricator
Path: blob/master/src/applications/differential/parser/DifferentialCommitMessageParser.php
12256 views
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
*/
22
final 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
* @task config
69
*/
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;
75
}
76
77
78
/**
79
* @task config
80
*/
81
public function getCommitMessageFields() {
82
return $this->commitMessageFields;
83
}
84
85
86
/**
87
* @task config
88
*/
89
public function setRaiseMissingFieldErrors($raise) {
90
$this->raiseMissingFieldErrors = $raise;
91
return $this;
92
}
93
94
95
/**
96
* @task config
97
*/
98
public function getRaiseMissingFieldErrors() {
99
return $this->raiseMissingFieldErrors;
100
}
101
102
103
/**
104
* @task config
105
*/
106
public function setLabelMap(array $label_map) {
107
$this->labelMap = $label_map;
108
return $this;
109
}
110
111
112
/**
113
* @task config
114
*/
115
public function setTitleKey($title_key) {
116
$this->titleKey = $title_key;
117
return $this;
118
}
119
120
121
/**
122
* @task config
123
*/
124
public function setSummaryKey($summary_key) {
125
$this->summaryKey = $summary_key;
126
return $this;
127
}
128
129
130
/* -( Parsing Messages )--------------------------------------------------- */
131
132
133
/**
134
* @task parse
135
*/
136
public function parseCorpus($corpus) {
137
$this->errors = array();
138
$this->xactions = array();
139
140
$label_map = $this->getLabelMap();
141
$key_title = $this->titleKey;
142
$key_summary = $this->summaryKey;
143
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'));
151
}
152
153
$label_regexp = $this->buildLabelRegexp($label_map);
154
155
// NOTE: We're special casing things here to make the "Title:" label
156
// optional in the message.
157
$field = $key_title;
158
159
$seen = array();
160
161
$lines = trim($corpus);
162
$lines = phutil_split_lines($lines, false);
163
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;
170
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']);
182
}
183
$seen[$field] = true;
184
}
185
}
186
187
$field_map[$key] = $field;
188
}
189
190
$fields = array();
191
foreach ($lines as $key => $line) {
192
$fields[$field_map[$key]][] = $line;
193
}
194
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;
205
}
206
}
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;
212
}
213
}
214
}
215
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;
221
}
222
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);
234
235
if ($short != $title) {
236
237
// If we shortened the title, split the rest into the summary, so
238
// we end up with a title like:
239
//
240
// Title title tile title title...
241
//
242
// ...and a summary like:
243
//
244
// ...title title title.
245
//
246
// Summary summary summary summary.
247
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");
253
254
$fields[$key_title] = $short;
255
$fields[$key_summary] = $summary;
256
}
257
}
258
259
return $fields;
260
}
261
262
263
/**
264
* @task parse
265
*/
266
public function parseFields($corpus) {
267
$viewer = $this->getViewer();
268
$text_map = $this->parseCorpus($corpus);
269
270
$field_map = $this->getCommitMessageFields();
271
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));
284
}
285
286
try {
287
$result = $field->parseFieldValue($text_value);
288
$result_map[$field_key] = $result;
289
290
try {
291
$xactions = $field->getFieldTransactions($result);
292
foreach ($xactions as $xaction) {
293
$this->xactions[] = $xaction;
294
}
295
} catch (Exception $ex) {
296
$this->errors[] = pht(
297
'Error extracting field transactions from "%s": %s',
298
$field->getFieldName(),
299
$ex->getMessage());
300
}
301
} catch (DifferentialFieldParseException $ex) {
302
$this->errors[] = pht(
303
'Error parsing field "%s": %s',
304
$field->getFieldName(),
305
$ex->getMessage());
306
}
307
308
}
309
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());
319
}
320
}
321
}
322
323
return $result_map;
324
}
325
326
327
/**
328
* @task parse
329
*/
330
public function getErrors() {
331
return $this->errors;
332
}
333
334
335
/**
336
* @task parse
337
*/
338
public function getTransactions() {
339
return $this->xactions;
340
}
341
342
343
/* -( Support Methods )---------------------------------------------------- */
344
345
346
/**
347
* @task support
348
*/
349
public static function normalizeFieldLabel($label) {
350
return phutil_utf8_strtolower($label);
351
}
352
353
354
/* -( Internals )---------------------------------------------------------- */
355
356
357
private function getLabelMap() {
358
if ($this->labelMap === null) {
359
$field_list = $this->getCommitMessageFields();
360
361
$label_map = array();
362
foreach ($field_list as $field_key => $field) {
363
$labels = $field->getFieldAliases();
364
$labels[] = $field->getFieldName();
365
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]));
376
}
377
378
$label_map[$normal_label] = $field_key;
379
}
380
}
381
382
$this->labelMap = $label_map;
383
}
384
385
return $this->labelMap;
386
}
387
388
389
/**
390
* @task internal
391
*/
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, '/');
396
}
397
$field_labels = implode('|', $field_labels);
398
399
$field_pattern = '/^(?P<field>'.$field_labels.'):(?P<text>.*)$/i';
400
401
return $field_pattern;
402
}
403
404
}
405
406