Path: blob/master/src/applications/differential/parser/DifferentialCommitMessageParser.php
12256 views
<?php12/**3* Parses commit messages (containing relatively freeform text with textual4* field labels) into a dictionary of fields.5*6* $parser = id(new DifferentialCommitMessageParser())7* ->setLabelMap($label_map)8* ->setTitleKey($key_title)9* ->setSummaryKey($key_summary);10*11* $fields = $parser->parseCorpus($corpus);12* $errors = $parser->getErrors();13*14* This is used by Differential to parse messages entered from the command line.15*16* @task config Configuring the Parser17* @task parse Parsing Messages18* @task support Support Methods19* @task internal Internals20*/21final class DifferentialCommitMessageParser extends Phobject {2223private $viewer;24private $labelMap;25private $titleKey;26private $summaryKey;27private $errors;28private $commitMessageFields;29private $raiseMissingFieldErrors = true;30private $xactions;3132public static function newStandardParser(PhabricatorUser $viewer) {33$key_title = DifferentialTitleCommitMessageField::FIELDKEY;34$key_summary = DifferentialSummaryCommitMessageField::FIELDKEY;3536$field_list = DifferentialCommitMessageField::newEnabledFields($viewer);3738return id(new self())39->setViewer($viewer)40->setCommitMessageFields($field_list)41->setTitleKey($key_title)42->setSummaryKey($key_summary);43}444546/* -( Configuring the Parser )--------------------------------------------- */474849/**50* @task config51*/52public function setViewer(PhabricatorUser $viewer) {53$this->viewer = $viewer;54return $this;55}565758/**59* @task config60*/61public function getViewer() {62return $this->viewer;63}646566/**67* @task config68*/69public function setCommitMessageFields(array $fields) {70assert_instances_of($fields, 'DifferentialCommitMessageField');71$fields = mpull($fields, null, 'getCommitMessageFieldKey');72$this->commitMessageFields = $fields;73return $this;74}757677/**78* @task config79*/80public function getCommitMessageFields() {81return $this->commitMessageFields;82}838485/**86* @task config87*/88public function setRaiseMissingFieldErrors($raise) {89$this->raiseMissingFieldErrors = $raise;90return $this;91}929394/**95* @task config96*/97public function getRaiseMissingFieldErrors() {98return $this->raiseMissingFieldErrors;99}100101102/**103* @task config104*/105public function setLabelMap(array $label_map) {106$this->labelMap = $label_map;107return $this;108}109110111/**112* @task config113*/114public function setTitleKey($title_key) {115$this->titleKey = $title_key;116return $this;117}118119120/**121* @task config122*/123public function setSummaryKey($summary_key) {124$this->summaryKey = $summary_key;125return $this;126}127128129/* -( Parsing Messages )--------------------------------------------------- */130131132/**133* @task parse134*/135public function parseCorpus($corpus) {136$this->errors = array();137$this->xactions = array();138139$label_map = $this->getLabelMap();140$key_title = $this->titleKey;141$key_summary = $this->summaryKey;142143if (!$key_title || !$key_summary || ($label_map === null)) {144throw new Exception(145pht(146'Expected %s, %s and %s to be set before parsing a corpus.',147'labelMap',148'summaryKey',149'titleKey'));150}151152$label_regexp = $this->buildLabelRegexp($label_map);153154// NOTE: We're special casing things here to make the "Title:" label155// optional in the message.156$field = $key_title;157158$seen = array();159160$lines = trim($corpus);161$lines = phutil_split_lines($lines, false);162163$field_map = array();164foreach ($lines as $key => $line) {165// We always parse the first line of the message as a title, even if it166// contains something we recognize as a field header.167if (!isset($seen[$key_title])) {168$field = $key_title;169170$lines[$key] = trim($line);171$seen[$field] = true;172} else {173$match = null;174if (preg_match($label_regexp, $line, $match)) {175$lines[$key] = trim($match['text']);176$field = $label_map[self::normalizeFieldLabel($match['field'])];177if (!empty($seen[$field])) {178$this->errors[] = pht(179'Field "%s" occurs twice in commit message!',180$match['field']);181}182$seen[$field] = true;183}184}185186$field_map[$key] = $field;187}188189$fields = array();190foreach ($lines as $key => $line) {191$fields[$field_map[$key]][] = $line;192}193194// This is a piece of special-cased magic which allows you to omit the195// field labels for "title" and "summary". If the user enters a large block196// of text at the beginning of the commit message with an empty line in it,197// treat everything before the blank line as "title" and everything after198// as "summary".199if (isset($fields[$key_title]) && empty($fields[$key_summary])) {200$lines = $fields[$key_title];201for ($ii = 0; $ii < count($lines); $ii++) {202if (strlen(trim($lines[$ii])) == 0) {203break;204}205}206if ($ii != count($lines)) {207$fields[$key_title] = array_slice($lines, 0, $ii);208$summary = array_slice($lines, $ii);209if (strlen(trim(implode("\n", $summary)))) {210$fields[$key_summary] = $summary;211}212}213}214215// Implode all the lines back into chunks of text.216foreach ($fields as $name => $lines) {217$data = rtrim(implode("\n", $lines));218$data = ltrim($data, "\n");219$fields[$name] = $data;220}221222// This is another piece of special-cased magic which allows you to223// enter a ridiculously long title, or just type a big block of stream224// of consciousness text, and have some sort of reasonable result conjured225// from it.226if (isset($fields[$key_title])) {227$terminal = '...';228$title = $fields[$key_title];229$short = id(new PhutilUTF8StringTruncator())230->setMaximumBytes(250)231->setTerminator($terminal)232->truncateString($title);233234if ($short != $title) {235236// If we shortened the title, split the rest into the summary, so237// we end up with a title like:238//239// Title title tile title title...240//241// ...and a summary like:242//243// ...title title title.244//245// Summary summary summary summary.246247$summary = idx($fields, $key_summary, '');248$offset = strlen($short) - strlen($terminal);249$remainder = ltrim(substr($fields[$key_title], $offset));250$summary = '...'.$remainder."\n\n".$summary;251$summary = rtrim($summary, "\n");252253$fields[$key_title] = $short;254$fields[$key_summary] = $summary;255}256}257258return $fields;259}260261262/**263* @task parse264*/265public function parseFields($corpus) {266$viewer = $this->getViewer();267$text_map = $this->parseCorpus($corpus);268269$field_map = $this->getCommitMessageFields();270271$result_map = array();272foreach ($text_map as $field_key => $text_value) {273$field = idx($field_map, $field_key);274if (!$field) {275// This is a strict error, since we only parse fields which we have276// been told are valid. The caller probably handed us an invalid label277// map.278throw new Exception(279pht(280'Parser emitted a field with key "%s", but no corresponding '.281'field definition exists.',282$field_key));283}284285try {286$result = $field->parseFieldValue($text_value);287$result_map[$field_key] = $result;288289try {290$xactions = $field->getFieldTransactions($result);291foreach ($xactions as $xaction) {292$this->xactions[] = $xaction;293}294} catch (Exception $ex) {295$this->errors[] = pht(296'Error extracting field transactions from "%s": %s',297$field->getFieldName(),298$ex->getMessage());299}300} catch (DifferentialFieldParseException $ex) {301$this->errors[] = pht(302'Error parsing field "%s": %s',303$field->getFieldName(),304$ex->getMessage());305}306307}308309if ($this->getRaiseMissingFieldErrors()) {310foreach ($field_map as $key => $field) {311try {312$field->validateFieldValue(idx($result_map, $key));313} catch (DifferentialFieldValidationException $ex) {314$this->errors[] = pht(315'Invalid or missing field "%s": %s',316$field->getFieldName(),317$ex->getMessage());318}319}320}321322return $result_map;323}324325326/**327* @task parse328*/329public function getErrors() {330return $this->errors;331}332333334/**335* @task parse336*/337public function getTransactions() {338return $this->xactions;339}340341342/* -( Support Methods )---------------------------------------------------- */343344345/**346* @task support347*/348public static function normalizeFieldLabel($label) {349return phutil_utf8_strtolower($label);350}351352353/* -( Internals )---------------------------------------------------------- */354355356private function getLabelMap() {357if ($this->labelMap === null) {358$field_list = $this->getCommitMessageFields();359360$label_map = array();361foreach ($field_list as $field_key => $field) {362$labels = $field->getFieldAliases();363$labels[] = $field->getFieldName();364365foreach ($labels as $label) {366$normal_label = self::normalizeFieldLabel($label);367if (!empty($label_map[$normal_label])) {368throw new Exception(369pht(370'Field label "%s" is parsed by two custom fields: "%s" and '.371'"%s". Each label must be parsed by only one field.',372$label,373$field_key,374$label_map[$normal_label]));375}376377$label_map[$normal_label] = $field_key;378}379}380381$this->labelMap = $label_map;382}383384return $this->labelMap;385}386387388/**389* @task internal390*/391private function buildLabelRegexp(array $label_map) {392$field_labels = array_keys($label_map);393foreach ($field_labels as $key => $label) {394$field_labels[$key] = preg_quote($label, '/');395}396$field_labels = implode('|', $field_labels);397398$field_pattern = '/^(?P<field>'.$field_labels.'):(?P<text>.*)$/i';399400return $field_pattern;401}402403}404405406